magicciv/src/simulator/crates/mc-player-api/src/dispatch.rs
Natalie 8981da14d1 feat(@projects/@magic-civilization): implement headless ai personality loading
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-11 09:28:43 -07:00

1992 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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_core::civic::{AxisChoice, CivicAxis as CoreCivicAxis};
use mc_turn::action_handlers;
use mc_turn::game_state::{AttackRequest, GameState};
use crate::action::{CivicAxis as WireCivicAxis, PlayerAction, RansomResponse};
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(),
}),
// ── Empire-level: civic / diplomacy / ransom ─────────────────────
PlayerAction::SwitchCivic { axis, choice } => {
apply_switch_civic(state, player, *axis, choice)
}
PlayerAction::DeclareWar { on } => apply_declare_war(state, player, *on),
PlayerAction::AcceptPeace { from } => apply_accept_peace(state, player, *from),
PlayerAction::RejectPeace { from: _ } => {
// Pure rejection event; no state mutation. `offer_peace` is an EA
// stub (always rejected), so there is nothing to remove from the
// pending-offers queue yet — the rejection is the natural state.
Ok(Vec::new())
}
PlayerAction::RespondToRansom { offer_id, response } => {
apply_ransom_response(state, player, *offer_id, *response)
}
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)
}
// p2-67 Phase 8 — formation / rally queue pushes. Every request
// struct already exists in `mc_core::formation`; the matching
// `pending_*` queue fields are already on `GameState`. Dispatch
// here just appends to the queue — the processor drains them
// during turn resolution.
PlayerAction::SetRallyPoint { unit_id, to } => apply_set_rally(state, unit_id, *to),
PlayerAction::ClearRallyPoint { unit_id } => apply_clear_rally(state, unit_id),
PlayerAction::CommandFormation { formation_id, command, to } => {
apply_command_formation(state, *formation_id, command, *to)
}
PlayerAction::SetFormationShape { formation_id, shape } => {
apply_set_formation_shape(state, *formation_id, shape)
}
PlayerAction::SplitFromFormation { unit_id } => {
apply_split_from_formation(state, unit_id)
}
PlayerAction::SetAutoJoin { unit_id, enabled } => {
apply_set_auto_join(state, unit_id, *enabled)
}
PlayerAction::QueueProduction { city_id, item, tile: _ } => {
apply_queue_production(state, player, city_id, item)
}
PlayerAction::RemoveFromQueue { city_id, index: _ } => {
apply_clear_queue(state, player, city_id)
}
PlayerAction::RushBuy { city_id } => apply_rush_buy(state, player, city_id),
PlayerAction::BuyTile { .. } => Err(ActionError::NotYetImplemented {
message: "buy_tile requires per-city tile-ownership state; bench \
CityState carries no `owned_tiles: HashSet<HexCoord>` field \
and widening it cascades into mc-sim/solo_dominion + \
fauna_pressure_bench serde compat. The full `City` struct \
in mc-city/src/city.rs owns tile ownership; a per-player \
parallel `owned_tiles: Vec<HexCoord>` on GameState is the \
lighter alternative. TRACKED: p2-67 Phase 7 follow-up."
.into(),
}),
PlayerAction::SetFocus { .. } => Err(ActionError::NotYetImplemented {
message: "set_focus targets `City::set_focus` on the full City struct \
(mc-city/src/city.rs); bench CityState has no `focus` field. \
Adding it requires widening every bench consumer (mc-sim, \
fauna_pressure_bench, MCTS rollout snapshots) for a field \
they ignore. TRACKED: p2-67 Phase 7 follow-up."
.into(),
}),
PlayerAction::QueueReorder { .. } => Err(ActionError::NotYetImplemented {
message: "queue_reorder operates on a Vec<QueueEntry>; bench CityState \
holds a single `queue: Option<Queueable>` so there is \
nothing to reorder. Upgrading to a vec cascades through \
every TurnProcessor production path. TRACKED: p2-67 Phase 7 \
follow-up (bench-queue-as-vec migration)."
.into(),
}),
PlayerAction::MergeBuildings { .. } => Err(ActionError::NotYetImplemented {
message: "merge_buildings calls mc_city::merge::apply_merge which \
requires &mut City + &BuildingRegistry + researched techs. \
Threading the registry through GameState is the larger \
lift; bench City struct (CityState) carries no instance \
data the merge engine needs. TRACKED: p2-67 Phase 7 \
follow-up (building-registry on GameState)."
.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 { tech_id } => {
apply_research_tech(state, player, tech_id)
}
PlayerAction::ResearchTradition { tradition_id } => {
apply_research_tradition(state, player, tradition_id)
}
PlayerAction::OfferPeace { to } => {
// mc-trade::offer_peace is an EA stub (always rejects). Emit a
// synthetic rejection so the adapter sees the round-trip.
let _ = mc_trade::offer_peace(player, *to);
Ok(Vec::new())
}
// p2-67 Phase 8 — OpenBorders / SharedMap signing on the bench.
//
// The full protocol is offer → accept/reject with a pending-offer
// staging area; on the headless Claude-API bench, the
// counterparty AI doesn't yet model offer acceptance, so we
// bench-cheat: every Offer immediately signs the agreement
// (instant-sign), and Accept/Reject are no-op acknowledgements.
// The honest contract is documented in
// `docs/CLAUDE_PLAYER_API.md` once Phase 8 doc updates land.
PlayerAction::OfferOpenBorders { to } => apply_offer_open_borders(state, player, *to),
PlayerAction::AcceptOpenBorders { from: _ }
| PlayerAction::RejectOpenBorders { from: _ }
| PlayerAction::AcceptSharedMap { from: _ }
| PlayerAction::RejectSharedMap { from: _ } => Ok(Vec::new()),
PlayerAction::OfferSharedMap { to } => apply_offer_shared_map(state, player, *to),
PlayerAction::Promote(pick) => apply_promote(state, &pick.unit_id, &pick.promotion_id),
}
}
fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>, ActionError> {
let ended_turn = state.turn;
let mut events: Vec<Event> = vec![
Event::TurnEnded {
turn: ended_turn,
player,
},
Event::PhaseChanged {
phase: "end_turn".into(),
},
];
// p2-68 Wave 4 — production AI driver. For each non-Claude slot:
// (1) project GameState → TacticalState via crate::projection::project_tactical,
// (2) run mc_ai::run_ai_turn to produce an Action chain,
// (3) apply each Action via apply_ai_action (which routes back through
// this same apply_action dispatcher for DRY semantics).
// The scripted heuristic that lived here is gone — p2-68's whole point.
for ai_slot in 0..state.players.len() {
let ai_slot_u8: u8 = ai_slot as u8;
if ai_slot_u8 == player {
continue;
}
let ai_actions: u32 = drive_ai_slot(state, ai_slot_u8);
events.push(Event::AiTurnStarted { player: ai_slot_u8 });
events.push(Event::AiTurnCompleted {
player: ai_slot_u8,
actions_applied: ai_actions,
});
}
// p2-67 Phase 11 — run `TurnProcessor::step` so production, growth,
// research, founding, pending_move_requests, fauna encounters all
// drain per turn. Without this the bench state was static between
// EndTurns (the p2-68 Wave-final smoke surfaced `actions_applied=0`
// on turns 1-N as a direct consequence).
//
// `step` increments `state.turn` (line 309 of processor.rs) and
// refreshes every unit's `movement_remaining` at end-of-step (DRY
// rule — the dispatch-level `refresh_units` call site is deleted in
// this same patch). A fresh `TurnProcessor::new(max_turns)` is built
// per call: the bench harness doesn't carry one across turns, and the
// processor is intentionally cheap to construct (no JSON loaded). When
// `state.victory_config` is None the processor falls back to the simple
// city-count check, matching pre-Phase-11 behaviour.
//
// p2-67 Phase 12 will then build a fresh `ObservationStore` BEFORE the
// AI loop so each AI's projected vision is current; today's call order
// (AI first, then step) is fine because the bench projector reads the
// `GameState` directly and doesn't use the observation store yet.
// `max_turns` is advisory in `step` — it only gates the turn-limit
// victory fallback. Headless callers don't enforce a turn cap, so
// pass a large sentinel; victory_config (when present) overrides.
let processor = mc_turn::processor::TurnProcessor::new(u32::MAX);
let result = processor.step(state);
// Translate processor events to wire events. The `clan: ClanId(u32)`
// field in every TurnEvent emit site is the player index (see e.g.
// `processor.rs:910` — `clan: mc_replay::ClanId(pi as u32)`), so the
// clan→player mapping is `id.0 as PlayerId`. Only variants with a
// direct wire counterpart get translated; everything else is dropped
// (the full chronicle remains available via the replay archive).
events.extend(translate_processor_events(&result.events_emitted));
let started_turn = state.turn;
events.push(Event::TurnStarted {
turn: started_turn,
player,
});
Ok(events)
}
/// p2-67 Phase 11 — translate the `mc_replay::TurnEvent` chronicle entries
/// emitted by `TurnProcessor::step` into the public `wire::Event` taxonomy
/// the Claude Player API surfaces.
///
/// Only variants with a direct wire-event counterpart are translated.
/// Everything else (AmbientEncounterFired, UnitKilled, CityCaptured,
/// EraEntered, LeaderChanged, etc.) is dropped on the floor — the
/// underlying state mutation already happened during `step`, so the
/// adapter can re-derive it from the next `view()` call. Full
/// chronicle-to-wire mapping is a future objective when adapters need
/// streaming combat / diplomacy events without polling `view`.
///
/// Every emit site in `mc_turn::processor` uses `ClanId(pi as u32)`
/// where `pi` is the player index — so `id.0 as PlayerId` is the
/// correct clan→player lookup with no separate table needed.
fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
let mut out: Vec<Event> = Vec::new();
for ev in events {
match ev {
mc_replay::TurnEvent::TechResearched { clan, tech, .. } => {
out.push(Event::TechResearched {
tech_id: tech.0.clone(),
player: clan.0 as PlayerId,
});
}
mc_replay::TurnEvent::WonderBuilt { clan, wonder, .. } => {
out.push(Event::WonderBuilt {
wonder_id: wonder.0.clone(),
player: clan.0 as PlayerId,
});
}
mc_replay::TurnEvent::CityFounded { clan, hex, .. } => {
let position: crate::WireHex = [hex.0 as i32, hex.1 as i32];
out.push(Event::CityFounded {
city_id: format!("city_{}_{}", clan.0, position[0]),
owner: clan.0 as PlayerId,
position,
});
}
mc_replay::TurnEvent::CityCaptured {
attacker,
defender,
hex,
..
} => {
let _ = hex;
out.push(Event::CityCaptured {
city_id: format!("city_{}", defender.0),
old_owner: defender.0 as PlayerId,
new_owner: attacker.0 as PlayerId,
});
}
mc_replay::TurnEvent::GameOver {
winner,
reason_kind,
condition,
..
} => {
if let Some(w) = winner {
let victory_type: String = condition
.clone()
.unwrap_or_else(|| reason_kind.clone());
out.push(Event::GameOver {
winner: w.0 as PlayerId,
victory_type,
});
}
}
// Variants without a direct wire counterpart — dropped per the
// docstring above. Listed explicitly so adding new TurnEvent
// variants forces a compile-time decision here.
mc_replay::TurnEvent::AmbientEncounterFired { .. }
| mc_replay::TurnEvent::UnitKilled { .. }
| mc_replay::TurnEvent::WarDeclared { .. }
| mc_replay::TurnEvent::PeaceSigned { .. }
| mc_replay::TurnEvent::EraEntered { .. }
| mc_replay::TurnEvent::LeaderChanged { .. }
| mc_replay::TurnEvent::ClanEliminated { .. }
| mc_replay::TurnEvent::UnitCaptured { .. }
| mc_replay::TurnEvent::UnitRansomOffered { .. }
| mc_replay::TurnEvent::CivilianDestroyed { .. } => {}
}
}
out
}
/// Drive one AI slot for one turn via the headless production pipeline
/// (p2-68 Wave 4). Returns the number of `mc_ai::Action`s applied.
///
/// Pipeline:
/// 1. Project `GameState` → `TacticalState` oriented for `ai_slot`.
/// 2. Pull this slot's `ScoringWeights` directly off `PlayerState`
/// (single source of truth — no parallel personalities table).
/// 3. Derive a per-turn rng seed from `(state.turn, ai_slot)` so the
/// same world state produces the same AI sequence.
/// 4. Call `mc_ai::run_ai_turn` to produce the action chain.
/// 5. Apply each action via `apply_ai_action`. Per-action errors are
/// counted but DO NOT abort the rest of the turn — a single
/// unknown unit or illegal move must not collapse the entire AI
/// side (matches the GDScript dispatch behaviour).
fn drive_ai_slot(state: &mut GameState, ai_slot: u8) -> u32 {
let pi: usize = ai_slot as usize;
if pi >= state.players.len() {
return 0;
}
let mut tactical = crate::projection::project_tactical(state, ai_slot);
tactical.current_player = ai_slot;
let weights = state.players[pi].scoring_weights.clone();
let seed = seed_for_ai_turn(state.turn, ai_slot);
let actions = mc_ai::tactical::run_ai_turn(&tactical, ai_slot, &weights, seed);
let mut applied: u32 = 0;
for action in actions {
match apply_ai_action(state, ai_slot, action) {
Ok(_) => {
applied += 1;
}
Err(_) => {
// Per-action failure (UnknownUnit, IllegalAction, ...) is
// tolerated — keep processing the rest of the chain.
}
}
}
applied
}
/// Derive a deterministic per-turn rng seed for `ai_slot`.
///
/// Pure function of `(turn, slot)` — no read of mutable per-turn state.
/// Different slots on the same turn get distinct seeds; the same slot on
/// successive turns also gets distinct seeds. Mixed via SplitMix64-style
/// constant (`0x9E37_79B9_7F4A_7C15`) so close inputs produce far-apart
/// outputs.
fn seed_for_ai_turn(turn: u32, ai_slot: u8) -> u64 {
(turn as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(ai_slot as u64)
}
/// Apply a tactical AI [`mc_ai::tactical::Action`] to the live `GameState`
/// by pattern-matching it down to one or more [`PlayerAction`] invocations
/// of the existing `apply_action` dispatcher. p2-68 Wave 2.
///
/// DRY rationale: the AI must not own a parallel action-mutation surface.
/// Every variant here either delegates into `apply_action` (single source
/// of truth for action semantics) or directly enqueues into the same
/// `pending_*` request vectors that the human/Claude path uses.
///
/// Variants without a `PlayerAction` analogue (`AssignCitizen`,
/// `DeploySiege`, `PackSiege`, `Bombard`) are accepted but emit no
/// events — they require subsystem wiring not yet in `PlayerAction` and
/// will land in a follow-up. Returning `Ok(Vec::new())` rather than
/// `NotYetImplemented` is deliberate: a single unrecognised AI action
/// must not abort the rest of the turn (the GDScript bridge has the same
/// behaviour — see `ai_turn_bridge_dispatch.gd::dispatch_one`).
///
/// # Errors
/// - [`ActionError::UnknownUnit`] — target unit id not found anywhere on
/// the map. Surfaces from `apply_action` for the underlying verb.
/// - All other errors propagated from `apply_action`.
pub fn apply_ai_action(
state: &mut GameState,
player: PlayerId,
action: mc_ai::tactical::Action,
) -> Result<Vec<Event>, ActionError> {
use mc_ai::tactical::Action as AiAction;
match action {
AiAction::MoveUnit { unit_id, to_hex } => {
let pa = PlayerAction::Move {
unit_id: unit_id.to_string(),
to: [to_hex.0, to_hex.1],
};
apply_action(state, player, &pa)
}
AiAction::AttackTarget { attacker_id, target_id, posture: _ } => {
// PlayerAction::Attack targets a hex; resolve target_id → hex
// from the live state. Posture is dropped at this layer — the
// engine's per-relation default applies; threading posture
// through the wire surface is its own follow-up.
let target_hex = locate_unit_hex(state, target_id).ok_or_else(|| {
ActionError::UnknownUnit {
unit_id: target_id.to_string(),
}
})?;
let pa = PlayerAction::Attack {
unit_id: attacker_id.to_string(),
target: [target_hex.0, target_hex.1],
};
apply_action(state, player, &pa)
}
AiAction::Fortify { unit_id } => {
let pa = PlayerAction::Fortify {
unit_id: unit_id.to_string(),
};
apply_action(state, player, &pa)
}
AiAction::Heal { unit_id } => {
// The engine heals fortified/idle units automatically at the
// turn boundary; "Heal" at the AI layer is operationally
// equivalent to "Skip" (skip remaining moves, stay put).
let pa = PlayerAction::Skip {
unit_id: unit_id.to_string(),
};
apply_action(state, player, &pa)
}
AiAction::FoundCity { settler_id, at_hex: _ } => {
// `PlayerAction::FoundCity` founds at the unit's CURRENT hex;
// the AI's `at_hex` is informational. Callers that need the
// unit moved into position first must emit a `MoveUnit`
// beforehand (which `decide_tactical_actions` already does).
let pa = PlayerAction::FoundCity {
unit_id: settler_id.to_string(),
};
apply_action(state, player, &pa)
}
AiAction::SetProduction { city_id, item_id } => {
let pa = PlayerAction::QueueProduction {
city_id: city_id.to_string(),
item: item_id,
tile: None,
};
apply_action(state, player, &pa)
}
AiAction::EnqueueBuild { city_id, item_id, building_origin: _ } => {
// p1-44c added per-building queues; the PlayerAction wire
// surface still routes everything through QueueProduction
// (the building-origin is resolved on the engine side from
// the item's `requires_building` field). Same `apply_action`
// call as `SetProduction` therefore — single dispatch path.
let pa = PlayerAction::QueueProduction {
city_id: city_id.to_string(),
item: item_id,
tile: None,
};
apply_action(state, player, &pa)
}
AiAction::Scout { unit_id, to_hex } => {
// Scout is a Move with intent — engine treats both the same.
let pa = PlayerAction::Move {
unit_id: unit_id.to_string(),
to: [to_hex.0, to_hex.1],
};
apply_action(state, player, &pa)
}
AiAction::IssuePatrol { unit_id, waypoints } => {
let pa = PlayerAction::IssuePatrol {
unit_id: unit_id.to_string(),
waypoints: waypoints.into_iter().map(|(c, r)| [c, r]).collect(),
};
apply_action(state, player, &pa)
}
AiAction::PromotionPicked { unit_id, promotion_id } => {
let pa = PlayerAction::Promote(crate::action::PromotionPick {
unit_id: unit_id.to_string(),
promotion_id,
});
apply_action(state, player, &pa)
}
// Variants without a corresponding PlayerAction wire variant.
// Quietly no-op so a single unmatched action does not abort the
// rest of the AI turn. TRACKED: extend the PlayerAction surface
// to cover citizen-assignment + siege verbs.
AiAction::AssignCitizen { .. }
| AiAction::DeploySiege { .. }
| AiAction::PackSiege { .. }
| AiAction::Bombard { .. } => Ok(Vec::new()),
}
}
/// Look up a unit by stable id and return its `(col, row)` hex.
fn locate_unit_hex(state: &GameState, unit_u32: u32) -> Option<(i32, i32)> {
for p in &state.players {
for u in &p.units {
if u.id == unit_u32 {
return Some((u.col, u.row));
}
}
}
None
}
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> {
// p2-67 Phase 9 — Proper Move subsystem.
//
// Queues a `MoveRequest` and drains it synchronously via
// `mc_turn::processor::process_move_requests`. The synchronous drain
// means each action returns its own events — matching the Claude-API
// contract where one request = one response.
//
// Production rules now apply:
// - movement_remaining must be > 0 (refresh_units is called at
// turn start in apply_end_turn)
// - the path is A*-validated via `mc-pathfinding`
// - per-tile movement cost is summed against the budget
// - destination occupancy still rejects (attack path is separate)
// - captive units cannot move (p2-55 ransom rules)
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?;
state
.pending_move_requests
.push(mc_turn::MoveRequest {
player_idx,
unit_idx,
target_col: to[0],
target_row: to[1],
});
let outcomes = mc_turn::processor::process_move_requests(state);
let outcome = outcomes
.into_iter()
.next()
.ok_or_else(|| ActionError::Internal {
message: "move drain produced no outcome".into(),
})?;
match outcome {
mc_turn::processor::MoveOutcome::Moved { from, to: dest, path, .. } => {
if from == dest {
// No-op (same-tile move) — keep the wire surface empty so
// adapters don't see redundant move events.
return Ok(Vec::new());
}
let wire_path: Vec<WireHex> =
path.into_iter().map(|(c, r)| [c, r]).collect();
Ok(vec![Event::UnitMoved {
unit_id: unit_id.to_string(),
from: [from.0, from.1],
to: [dest.0, dest.1],
path: wire_path,
}])
}
mc_turn::processor::MoveOutcome::Rejected { reason, .. } => {
Err(ActionError::TargetInvalid {
message: format!(
"move to [{}, {}] rejected: {reason}",
to[0], to[1]
),
})
}
}
}
fn apply_switch_civic(
state: &mut GameState,
player: PlayerId,
axis: WireCivicAxis,
choice_id: &str,
) -> Result<Vec<Event>, ActionError> {
let player_idx = player as usize;
if player_idx >= state.players.len() {
return Err(ActionError::Internal {
message: format!("player slot {player} out of range"),
});
}
let core_axis = match axis {
WireCivicAxis::Authority => CoreCivicAxis::Authority,
WireCivicAxis::Labor => CoreCivicAxis::Labor,
WireCivicAxis::Economy => CoreCivicAxis::Economy,
};
// Parse the choice id by round-tripping through JSON so well-known ids
// resolve to their dedicated variant (Monarchy / Guilds / etc.) while
// unknown catalog entries fall through to `AxisChoice::Custom`.
let choice_json = serde_json::to_string(&choice_id).map_err(|e| ActionError::Internal {
message: format!("choice JSON encode failed: {e}"),
})?;
let core_choice: AxisChoice = serde_json::from_str(&choice_json).map_err(|e| {
ActionError::IllegalAction {
message: format!("unknown civic choice '{choice_id}': {e}"),
}
})?;
let _ = state.players[player_idx]
.civic_state
.switch_axis(core_axis, core_choice);
// Civic switching is a state-only mutation; mc-core::civic doesn't emit
// events at this layer. Adapters detect the change via the next view's
// civics.anarchy_turns_remaining and the per-axis choice fields.
Ok(Vec::new())
}
fn apply_declare_war(
state: &mut GameState,
player: PlayerId,
against: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (against as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("declare_war target {against} out of range"),
});
}
// p2-67 Phase 8: TradeLedger now lives on `GameState`. War
// declaration breaks every active OpenBorders / SharedMap with the
// target (handled inside `mc_trade::declare_war`); break events
// surface through the second tuple slot — currently dropped here
// because the wire `Event` enum has no `OpenBordersBroken` variant
// yet. TRACKED Phase 11 follow-up: surface break events as
// `Event::DiplomacyBroken { kind, with }`.
// Authoritative `relations` lives on player 0 (see field doc on
// PlayerState::relations).
let _ = mc_trade::declare_war(
&mut state.players[0].relations,
&mut state.trade_ledger,
player,
against,
);
Ok(Vec::new())
}
fn apply_accept_peace(
state: &mut GameState,
player: PlayerId,
from: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (from as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("accept_peace source {from} out of range"),
});
}
let _ = mc_trade::accept_peace(&mut state.players[0].relations, player, from);
Ok(Vec::new())
}
fn find_city_indices(
state: &GameState,
city_id: &str,
) -> Result<(usize, usize), ActionError> {
// City id convention used by the projector: "{player_idx}_{city_idx}"
// (see projection::project_cities). Parse + bounds-check both halves.
let (pi_str, ci_str) = city_id
.split_once('_')
.ok_or_else(|| ActionError::UnknownCity {
city_id: city_id.to_string(),
})?;
let pi: usize = pi_str.parse().map_err(|_| ActionError::UnknownCity {
city_id: city_id.to_string(),
})?;
let ci: usize = ci_str.parse().map_err(|_| ActionError::UnknownCity {
city_id: city_id.to_string(),
})?;
if pi >= state.players.len() {
return Err(ActionError::UnknownCity {
city_id: city_id.to_string(),
});
}
if ci >= state.players[pi].cities.len() {
return Err(ActionError::UnknownCity {
city_id: city_id.to_string(),
});
}
Ok((pi, ci))
}
fn apply_queue_production(
state: &mut GameState,
_player: PlayerId,
city_id: &str,
item: &str,
) -> Result<Vec<Event>, ActionError> {
let (pi, ci) = find_city_indices(state, city_id)?;
// Bench `CityState` carries a single-item `queue: Option<Queueable>`.
// Detect whether the requested id is a known unit or treat as building.
// Heuristic: if the id contains "dwarf_" we treat as Unit; otherwise
// Item (the bench struct doesn't carry a Building variant — full
// City does, tracked separately).
use mc_core::ids::UnitId;
let queueable: mc_city::Queueable = if item.starts_with("dwarf_") {
mc_city::Queueable::Unit {
unit_id: UnitId::new(item),
}
} else {
mc_city::Queueable::Item {
item_id: item.to_string(),
}
};
let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci];
city.queue = Some(queueable);
// Reset production_stored when the queue head changes.
city.production_stored = 0;
// Default cost when the caller did not supply one; processor will
// recompute on first tick if a real registry is available.
if city.queue_cost.is_none() {
city.queue_cost = Some(40);
}
Ok(Vec::new())
}
/// RushBuy: pay `2 × queue_cost` gold to immediately complete the current
/// queue head.
///
/// Mirrors what `TurnProcessor::process_city_production` does on natural
/// completion — only the timing differs. State changes:
/// - `state.players[pi].gold -= rush_cost`
/// - For `Queueable::Wonder`: insert into `player.wonders_built` at the
/// stored tier, clear queue fields. Emits `Event::WonderBuilt`.
/// - For `Queueable::Unit`: clear queue fields, emit `Event::CityUnitCompleted`.
/// Does NOT spawn the unit on the map — the bench `TurnProcessor` itself
/// does not spawn units from non-wonder queue heads in Phase 7's scope
/// (that ticking lands in Phase 11). The wire event is the honest
/// observable; the next `view()` shows the cleared queue + reduced gold.
/// - For `Queueable::Item`: clear queue fields, emit `Event::CityBuildingCompleted`
/// (the closest existing semantic — items don't currently have a
/// dedicated completion event variant).
///
/// # Errors
///
/// - [`ActionError::UnknownCity`] — `city_id` doesn't resolve.
/// - [`ActionError::IllegalAction`] — empty queue (nothing to rush) or
/// insufficient gold.
fn apply_rush_buy(
state: &mut GameState,
_player: PlayerId,
city_id: &str,
) -> Result<Vec<Event>, ActionError> {
let (pi, ci) = find_city_indices(state, city_id)?;
// Snapshot the queue head + cost before mutating; the `Queueable` clone
// is cheap and lets us emit the right event variant after the borrow ends.
let (queueable, queue_cost, queue_tier) = {
let city = &state.players[pi].cities[ci];
let q = city.queue.clone().ok_or_else(|| ActionError::IllegalAction {
message: format!("city '{city_id}' has nothing queued to rush"),
})?;
let cost = city.queue_cost.ok_or_else(|| ActionError::IllegalAction {
message: format!(
"city '{city_id}' queue head has no `queue_cost`; rush requires a known base cost"
),
})?;
(q, cost, city.queue_tier)
};
let rush_cost: i32 = mc_items::ItemSystem::rush_buy_cost(queue_cost as i32);
if state.players[pi].gold < rush_cost {
return Err(ActionError::IllegalAction {
message: format!(
"rush_buy on '{city_id}' costs {rush_cost} gold; player has {}",
state.players[pi].gold
),
});
}
// Deduct gold first; any further failure is unreachable after the snapshot.
state.players[pi].gold -= rush_cost;
// Build the completion event from the queue-head snapshot.
let event: Event = match &queueable {
mc_city::Queueable::Wonder { wonder_id } => {
let tier = queue_tier.unwrap_or(1);
state.players[pi]
.wonders_built
.insert(wonder_id.clone(), tier);
Event::WonderBuilt {
wonder_id: wonder_id.0.clone(),
player: pi as u8,
}
}
mc_city::Queueable::Unit { unit_id } => Event::CityUnitCompleted {
city_id: city_id.to_string(),
unit_id: unit_id.0.clone(),
},
mc_city::Queueable::Item { item_id } => Event::CityBuildingCompleted {
city_id: city_id.to_string(),
building_id: item_id.clone(),
},
};
// Clear the queue head — matches `TurnProcessor` wonder-completion semantics.
let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci];
city.queue = None;
city.queue_cost = None;
city.queue_tier = None;
city.production_stored = 0;
Ok(vec![event])
}
fn apply_clear_queue(
state: &mut GameState,
_player: PlayerId,
city_id: &str,
) -> Result<Vec<Event>, ActionError> {
let (pi, ci) = find_city_indices(state, city_id)?;
let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci];
city.queue = None;
city.queue_cost = None;
city.queue_tier = None;
city.production_stored = 0;
Ok(Vec::new())
}
fn apply_research_tech(
state: &mut GameState,
player: PlayerId,
tech_id: &str,
) -> Result<Vec<Event>, ActionError> {
let pi: usize = player as usize;
if pi >= state.players.len() {
return Err(ActionError::Internal {
message: format!("player slot {player} out of range"),
});
}
// Lazy-init `player_tech` so adapters can pick research even when
// the harness hasn't seeded a PlayerTechState yet.
if state.players[pi].player_tech.is_none() {
state.players[pi].player_tech = Some(mc_tech::PlayerTechState::new());
}
let pt: &mut mc_tech::PlayerTechState =
state.players[pi].player_tech.as_mut().unwrap();
let _ = pt.set_researching_unchecked(tech_id);
Ok(Vec::new())
}
fn apply_research_tradition(
state: &mut GameState,
player: PlayerId,
tradition_id: &str,
) -> Result<Vec<Event>, ActionError> {
let pi: usize = player as usize;
if pi >= state.players.len() {
return Err(ActionError::Internal {
message: format!("player slot {player} out of range"),
});
}
// Bench tradition state lives in flat fields on `PlayerState` —
// see `game_state.rs::researching_tradition` (a String). Set
// directly; the turn processor reads from that field.
state.players[pi].researching_tradition = tradition_id.to_string();
state.players[pi].culture_research_progress = 0;
Ok(Vec::new())
}
fn apply_ransom_response(
state: &mut GameState,
player: PlayerId,
offer_id: u32,
response: RansomResponse,
) -> Result<Vec<Event>, ActionError> {
let offer = match response {
RansomResponse::Accept => state.ransom_queue.accept(offer_id),
RansomResponse::Refuse => state.ransom_queue.refuse(offer_id),
};
let _ = offer.ok_or_else(|| ActionError::IllegalAction {
message: format!("ransom offer {offer_id} not found"),
})?;
// Gold deduction + ownership flip on accept / refuse happen in the
// production resolver path (mc-turn::ransom drains the queue at
// end-of-turn). Here we trust that path; the dispatcher's job is to
// record the player's decision by removing the offer from the queue.
// TRACKED: p2-67 Phase 1 follow-up — inline gold + ownership mutation.
let _ = player;
Ok(Vec::new())
}
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 (attacker_player, attacker_unit) = find_unit_indices(state, unit_u32)?;
// Locate the defender by scanning every player's units for an
// occupant of the target hex. AttackRequest is indexed (not hexed)
// so the queue stays cheap to drain.
let (defender_player, defender_unit) = find_unit_at_hex(state, target).ok_or_else(|| {
ActionError::TargetInvalid {
message: format!("no defender at hex [{}, {}]", target[0], target[1]),
}
})?;
state.pending_pvp_attacks.push(AttackRequest {
attacker_player: attacker_player as u8,
attacker_unit,
defender_player: defender_player as u8,
defender_unit,
});
// Attack resolution happens during end-of-turn processing. No
// synchronous events at queue time.
Ok(Vec::new())
}
fn find_unit_at_hex(state: &GameState, hex: WireHex) -> Option<(usize, usize)> {
for (p_idx, player) in state.players.iter().enumerate() {
if let Some(u_idx) = player.units.iter().position(|u| u.col == hex[0] && u.row == hex[1]) {
return Some((p_idx, u_idx));
}
}
None
}
// ── p2-67 Phase 8 — formation / rally / promote / open-borders dispatch ──
fn apply_set_rally(
_state: &mut GameState,
_unit_id: &str,
_to: WireHex,
) -> Result<Vec<Event>, ActionError> {
// `pending_rally_requests` is keyed by `(player_index, city_index,
// building_id)` — it sets rally on BUILDINGS, not on units. The wire
// surface `SetRallyPoint { unit_id, to }` is per-unit. Routing through
// building rally requires (a) resolving which building produced the
// unit (not tracked) or (b) authoring a separate per-unit
// `pending_unit_rally_requests` queue. Both are bigger lifts than the
// 5-line dispatch the brief promised.
Err(ActionError::NotYetImplemented {
message: "set_rally — wire action is per-unit, but \
mc_core::RallyPointRequest is per-building. Needs either \
unit→building back-resolution or a new per-unit rally \
queue. TRACKED: p2-67 Phase 8 follow-up."
.into(),
})
}
fn apply_clear_rally(
_state: &mut GameState,
_unit_id: &str,
) -> Result<Vec<Event>, ActionError> {
Err(ActionError::NotYetImplemented {
message: "clear_rally — same per-unit vs per-building schema gap \
as set_rally. TRACKED: p2-67 Phase 8 follow-up."
.into(),
})
}
fn apply_command_formation(
state: &mut GameState,
formation_id: u32,
command: &str,
to: Option<WireHex>,
) -> Result<Vec<Event>, ActionError> {
// FormationCommandRequest needs the owning player index. Resolve it
// by looking up the formation owner on state.
let player_index = state
.formations
.get(&formation_id)
.map(|f| f.owner)
.ok_or_else(|| ActionError::IllegalAction {
message: format!("unknown formation id {formation_id}"),
})?;
let destination = to.map(|h| (h[0], h[1])).unwrap_or((-1, -1));
state
.pending_formation_commands
.push(mc_core::formation::FormationCommandRequest {
player_index,
formation_id,
destination,
command: command.to_string(),
});
Ok(Vec::new())
}
fn apply_set_formation_shape(
state: &mut GameState,
formation_id: u32,
shape: &str,
) -> Result<Vec<Event>, ActionError> {
let player_index = state
.formations
.get(&formation_id)
.map(|f| f.owner)
.ok_or_else(|| ActionError::IllegalAction {
message: format!("unknown formation id {formation_id}"),
})?;
state
.pending_formation_shapes
.push(mc_core::formation::FormationShapeRequest {
player_index,
formation_id,
shape: shape.to_string(),
});
Ok(Vec::new())
}
fn apply_split_from_formation(
state: &mut GameState,
unit_id: &str,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, _) = find_unit_indices(state, unit_u32)?;
state
.pending_split_requests
.push(mc_core::formation::SplitFormationRequest {
player_index: player_idx as u8,
unit_id: unit_u32,
});
Ok(Vec::new())
}
fn apply_set_auto_join(
state: &mut GameState,
unit_id: &str,
enabled: bool,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, _) = find_unit_indices(state, unit_u32)?;
state
.pending_auto_join_requests
.push(mc_core::formation::AutoJoinRequest {
player_index: player_idx as u8,
unit_id: unit_u32,
enabled,
});
Ok(Vec::new())
}
fn apply_promote(
state: &mut GameState,
unit_id: &str,
promotion_id: &str,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?;
if promotion_id.is_empty() {
return Err(ActionError::IllegalAction {
message: "promotion_id must be non-empty".into(),
});
}
state.players[player_idx].units[unit_idx].pending_promotion =
Some(promotion_id.to_string());
Ok(vec![Event::UnitPromoted {
unit_id: unit_id.to_string(),
promotion: promotion_id.to_string(),
}])
}
fn apply_offer_open_borders(
state: &mut GameState,
from: PlayerId,
to: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (to as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("open_borders target {to} out of range"),
});
}
if from == to {
return Err(ActionError::IllegalAction {
message: "cannot open borders with self".into(),
});
}
let pair = if from < to { (from, to) } else { (to, from) };
// Bench-cheat (documented in CLAUDE_PLAYER_API.md): the
// counterparty AI doesn't yet model offer acceptance, so Offer
// signs immediately. Real protocol with pending-offer staging
// is a Phase 11+ follow-up.
let id = state.trade_ledger.alloc_agreement_id();
state
.trade_ledger
.agreements
.push(mc_trade::DiplomaticAgreement::OpenBorders(
mc_trade::OpenBordersAgreement {
agreement_id: id,
partners: pair,
turn_started: state.turn,
turns_remaining: 30, // canonical Game-1 duration
payment_gold: 0,
payment_luxury: None,
},
));
Ok(Vec::new())
}
fn apply_offer_shared_map(
state: &mut GameState,
from: PlayerId,
to: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (to as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("shared_map target {to} out of range"),
});
}
if from == to {
return Err(ActionError::IllegalAction {
message: "cannot share map with self".into(),
});
}
let pair = if from < to { (from, to) } else { (to, from) };
let id = state.trade_ledger.alloc_agreement_id();
state
.trade_ledger
.agreements
.push(mc_trade::DiplomaticAgreement::SharedMap(
mc_trade::SharedMapAgreement {
agreement_id: id,
partners: pair,
turn_started: state.turn,
duration: 30,
share_turns_remaining: 0,
payment_gold: 0,
payment_luxury: None,
courier_route: None,
},
));
Ok(Vec::new())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::PlayerAction;
use mc_turn::game_state::{MapUnit, PlayerState};
fn make_state_with_units(units: Vec<(u8, u32, i32, i32)>) -> GameState {
// Vec<(owner_player_idx, unit_id, col, row)>. Creates as many
// PlayerState entries as needed to host every unique owner.
let mut state = GameState::default();
let max_owner = units.iter().map(|(o, _, _, _)| *o).max().unwrap_or(0);
for p in 0..=max_owner {
let mut ps = PlayerState::default();
ps.player_index = p;
state.players.push(ps);
}
for (owner, id, col, row) in units {
let mut unit = MapUnit::default();
unit.id = id;
unit.col = col;
unit.row = row;
// p2-67 Phase 9: tests run without a UnitsCatalog, so
// movement_remaining defaults to 0 — give every test unit a
// generous 32 mp via the builder so existing happy-path tests
// ("move from (0,0) to (3,5)") keep their geometry budget.
unit = unit.with_moves(32);
state.players[owner as usize].units.push(unit);
state.next_unit_id = id + 1;
}
state
}
fn empty_state_with_one_unit(unit_id: u32) -> GameState {
make_state_with_units(vec![(0, unit_id, 0, 0)])
}
#[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_resolving_defender_by_hex() {
// P0 attacker at (0,0), P1 defender at (5,7).
let mut state = make_state_with_units(vec![(0, 42, 0, 0), (1, 99, 5, 7)]);
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_player, 0);
assert_eq!(req.defender_player, 1);
assert_eq!(req.attacker_unit, 0);
assert_eq!(req.defender_unit, 0);
}
#[test]
fn attack_with_no_defender_at_target_returns_target_invalid() {
let mut state = empty_state_with_one_unit(42);
let err = apply_action(
&mut state,
0,
&PlayerAction::Attack {
unit_id: "42".into(),
target: [9, 9],
},
)
.unwrap_err();
assert!(matches!(err, ActionError::TargetInvalid { .. }));
assert!(state.pending_pvp_attacks.is_empty());
}
// Move dispatch landed in Wave 2 (trust-the-caller v1) — see
// `move_to_empty_hex_updates_unit_position_and_emits_unit_moved` and the
// related tests for the new behaviour. The old "returns NotYetImplemented"
// assertion was removed when the body switched from stub to live.
#[test]
fn buy_tile_returns_not_yet_implemented_with_bench_widening_breadcrumb() {
// BuyTile / SetFocus / QueueReorder / MergeBuildings remain
// NotYetImplemented because the bench `CityState` doesn't carry the
// fields the production City struct does. Their breadcrumbs cite the
// specific missing fields + the cascade cost (see dispatch.rs comments).
// RushBuy + QueueProduction + RemoveFromQueue ARE live.
let mut state = GameState::default();
let err = apply_action(
&mut state,
0,
&PlayerAction::BuyTile {
city_id: "0_0".into(),
tile: [0, 0],
},
)
.unwrap_err();
assert!(matches!(err, ActionError::NotYetImplemented { .. }));
}
fn make_city_with_queue(
gold: i32,
queue: Option<mc_city::Queueable>,
queue_cost: Option<u32>,
queue_tier: Option<u8>,
) -> GameState {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let mut city = mc_city::CityState::starter();
city.queue = queue;
city.queue_cost = queue_cost;
city.queue_tier = queue_tier;
city.production_stored = 10; // sentinel: should be reset to 0 by rush
state.players[0].cities.push(city);
state.players[0].gold = gold;
state
}
#[test]
fn rush_buy_unit_queue_deducts_2x_cost_clears_queue_emits_unit_completed() {
use mc_core::ids::UnitId;
let mut state = make_city_with_queue(
200,
Some(mc_city::Queueable::Unit {
unit_id: UnitId::new("dwarf_warrior"),
}),
Some(40),
None,
);
let events = apply_action(
&mut state,
0,
&PlayerAction::RushBuy {
city_id: "0_0".into(),
},
)
.unwrap();
assert_eq!(state.players[0].gold, 200 - 80, "gold = 200 - (2 × 40)");
assert!(state.players[0].cities[0].queue.is_none());
assert_eq!(state.players[0].cities[0].queue_cost, None);
assert_eq!(state.players[0].cities[0].production_stored, 0);
assert_eq!(events.len(), 1);
match &events[0] {
Event::CityUnitCompleted { city_id, unit_id } => {
assert_eq!(city_id, "0_0");
assert_eq!(unit_id, "dwarf_warrior");
}
other => panic!("expected CityUnitCompleted, got {other:?}"),
}
}
#[test]
fn rush_buy_item_queue_emits_city_building_completed() {
let mut state = make_city_with_queue(
500,
Some(mc_city::Queueable::Item {
item_id: "iron_sword".into(),
}),
Some(60),
None,
);
let events = apply_action(
&mut state,
0,
&PlayerAction::RushBuy {
city_id: "0_0".into(),
},
)
.unwrap();
assert_eq!(state.players[0].gold, 500 - 120);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], Event::CityBuildingCompleted { .. }));
}
#[test]
fn rush_buy_wonder_queue_records_in_wonders_built_and_emits_wonder_built() {
use mc_core::wonder::WonderId;
let mut state = make_city_with_queue(
1000,
Some(mc_city::Queueable::Wonder {
wonder_id: WonderId("hanging_gardens".to_string()),
}),
Some(200),
Some(3),
);
let events = apply_action(
&mut state,
0,
&PlayerAction::RushBuy {
city_id: "0_0".into(),
},
)
.unwrap();
assert_eq!(state.players[0].gold, 1000 - 400);
let tier = state.players[0]
.wonders_built
.get(&WonderId("hanging_gardens".to_string()))
.copied()
.expect("wonder must be inserted with stored tier");
assert_eq!(tier, 3);
assert!(state.players[0].cities[0].queue.is_none());
assert_eq!(events.len(), 1);
match &events[0] {
Event::WonderBuilt { wonder_id, player } => {
assert_eq!(wonder_id, "hanging_gardens");
assert_eq!(*player, 0);
}
other => panic!("expected WonderBuilt, got {other:?}"),
}
}
#[test]
fn rush_buy_with_empty_queue_returns_illegal_action() {
let mut state = make_city_with_queue(1000, None, None, None);
let err = apply_action(
&mut state,
0,
&PlayerAction::RushBuy {
city_id: "0_0".into(),
},
)
.unwrap_err();
assert!(matches!(err, ActionError::IllegalAction { .. }));
assert_eq!(state.players[0].gold, 1000, "gold untouched on failure");
}
#[test]
fn rush_buy_with_insufficient_gold_returns_illegal_action_and_no_mutation() {
use mc_core::ids::UnitId;
let mut state = make_city_with_queue(
50,
Some(mc_city::Queueable::Unit {
unit_id: UnitId::new("dwarf_warrior"),
}),
Some(40),
None,
);
let err = apply_action(
&mut state,
0,
&PlayerAction::RushBuy {
city_id: "0_0".into(),
},
)
.unwrap_err();
assert!(matches!(err, ActionError::IllegalAction { .. }));
assert_eq!(state.players[0].gold, 50, "gold untouched on insufficient");
assert!(
state.players[0].cities[0].queue.is_some(),
"queue untouched on insufficient"
);
}
#[test]
fn rush_buy_unknown_city_returns_unknown_city() {
let mut state = GameState::default();
let err = apply_action(
&mut state,
0,
&PlayerAction::RushBuy {
city_id: "99_99".into(),
},
)
.unwrap_err();
assert!(matches!(err, ActionError::UnknownCity { .. }));
}
#[test]
fn open_borders_offer_signs_agreement_on_bench() {
// p2-67 Phase 8: bench-cheat semantics — Offer signs immediately
// (counterparty AI doesn't yet model acceptance). The ledger
// grows by one DiplomaticAgreement::OpenBorders entry.
let mut state = GameState::default();
// Need at least 2 player slots for the target to be in-range.
state.players.push(PlayerState::default());
state.players.push(PlayerState::default());
let events = apply_action(
&mut state,
0,
&PlayerAction::OfferOpenBorders { to: 1 },
)
.unwrap();
assert!(events.is_empty(), "bench Offer emits no events synchronously");
assert_eq!(state.trade_ledger.agreements.len(), 1, "agreement signed");
match &state.trade_ledger.agreements[0] {
mc_trade::DiplomaticAgreement::OpenBorders(ag) => {
assert_eq!(ag.partners, (0, 1));
}
other => panic!("expected OpenBorders, got {other:?}"),
}
}
#[test]
fn shared_map_offer_signs_agreement_on_bench() {
let mut state = GameState::default();
state.players.push(PlayerState::default());
state.players.push(PlayerState::default());
let events = apply_action(
&mut state,
0,
&PlayerAction::OfferSharedMap { to: 1 },
)
.unwrap();
assert!(events.is_empty());
assert_eq!(state.trade_ledger.agreements.len(), 1);
assert!(matches!(
&state.trade_ledger.agreements[0],
mc_trade::DiplomaticAgreement::SharedMap(_)
));
}
#[test]
fn promote_sets_pending_promotion_and_emits_event() {
let mut state = empty_state_with_one_unit(42);
let events = apply_action(
&mut state,
0,
&PlayerAction::Promote(crate::action::PromotionPick {
unit_id: "42".into(),
promotion_id: "shock".into(),
}),
)
.unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], Event::UnitPromoted { .. }));
let u = &state.players[0].units[0];
assert_eq!(u.pending_promotion.as_deref(), Some("shock"));
}
#[test]
fn promote_empty_promotion_id_returns_illegal() {
let mut state = empty_state_with_one_unit(42);
let err = apply_action(
&mut state,
0,
&PlayerAction::Promote(crate::action::PromotionPick {
unit_id: "42".into(),
promotion_id: String::new(),
}),
)
.unwrap_err();
assert!(matches!(err, ActionError::IllegalAction { .. }));
}
#[test]
fn split_from_formation_queues_request() {
let mut state = empty_state_with_one_unit(42);
let _ = apply_action(
&mut state,
0,
&PlayerAction::SplitFromFormation { unit_id: "42".into() },
)
.unwrap();
assert_eq!(state.pending_split_requests.len(), 1);
assert_eq!(state.pending_split_requests[0].unit_id, 42);
}
#[test]
fn set_auto_join_queues_request() {
let mut state = empty_state_with_one_unit(42);
let _ = apply_action(
&mut state,
0,
&PlayerAction::SetAutoJoin { unit_id: "42".into(), enabled: false },
)
.unwrap();
assert_eq!(state.pending_auto_join_requests.len(), 1);
assert!(!state.pending_auto_join_requests[0].enabled);
}
#[test]
fn set_rally_returns_not_yet_implemented() {
let mut state = empty_state_with_one_unit(42);
let err = apply_action(
&mut state,
0,
&PlayerAction::SetRallyPoint { unit_id: "42".into(), to: [3, 3] },
)
.unwrap_err();
assert!(matches!(err, ActionError::NotYetImplemented { .. }));
}
#[test]
fn move_to_empty_hex_updates_unit_position_and_emits_unit_moved() {
let mut state = empty_state_with_one_unit(42);
// Unit starts at (0,0); move to (3, 5).
let events = apply_action(
&mut state,
0,
&PlayerAction::Move {
unit_id: "42".into(),
to: [3, 5],
},
)
.unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
Event::UnitMoved { unit_id, from, to, .. } => {
assert_eq!(unit_id, "42");
assert_eq!(*from, [0, 0]);
assert_eq!(*to, [3, 5]);
}
other => panic!("expected UnitMoved, got {other:?}"),
}
let u = &state.players[0].units[0];
assert_eq!(u.col, 3);
assert_eq!(u.row, 5);
}
#[test]
fn move_to_occupied_hex_returns_target_invalid() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 4, 4)]);
let err = apply_action(
&mut state,
0,
&PlayerAction::Move {
unit_id: "1".into(),
to: [4, 4],
},
)
.unwrap_err();
assert!(matches!(err, ActionError::TargetInvalid { .. }));
// Source unit unmoved.
let u = &state.players[0].units[0];
assert_eq!((u.col, u.row), (0, 0));
}
#[test]
fn move_to_same_hex_is_a_noop() {
let mut state = empty_state_with_one_unit(7);
// Unit at (0, 0); move to (0, 0).
let events = apply_action(
&mut state,
0,
&PlayerAction::Move {
unit_id: "7".into(),
to: [0, 0],
},
)
.unwrap();
assert!(events.is_empty(), "same-hex move should emit no events");
}
#[test]
fn switch_civic_mutates_axis_choice_and_starts_anarchy() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let events = apply_action(
&mut state,
0,
&PlayerAction::SwitchCivic {
axis: crate::action::CivicAxis::Authority,
choice: "monarchy".into(),
},
)
.unwrap();
assert!(events.is_empty(), "civic switch emits no synchronous events");
assert_eq!(
state.players[0].civic_state.authority,
mc_core::civic::AxisChoice::Monarchy
);
assert!(
state.players[0].civic_state.anarchy_turns_remaining > 0,
"anarchy timer must start after a real civic switch"
);
}
#[test]
fn declare_war_flips_relation_to_war() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 4, 4)]);
let _ = apply_action(&mut state, 0, &PlayerAction::DeclareWar { on: 1 }).unwrap();
let rs = state.players[0]
.relations
.get(&(0, 1))
.expect("relation must exist after declare_war");
assert_eq!(rs.relation, mc_trade::relation::Relation::War);
}
#[test]
fn accept_peace_resets_relation_to_neutral() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 4, 4)]);
// First declare war so there's something to accept peace on.
apply_action(&mut state, 0, &PlayerAction::DeclareWar { on: 1 }).unwrap();
apply_action(&mut state, 0, &PlayerAction::AcceptPeace { from: 1 }).unwrap();
let rs = state.players[0].relations.get(&(0, 1)).unwrap();
assert_eq!(rs.relation, mc_trade::relation::Relation::Neutral);
}
#[test]
fn queue_production_with_unit_id_sets_unit_queueable() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
// Add a starter city so QueueProduction has a target.
state.players[0].cities.push(mc_city::CityState::starter());
let _ = apply_action(
&mut state,
0,
&PlayerAction::QueueProduction {
city_id: "0_0".into(),
item: "dwarf_warrior".into(),
tile: None,
},
)
.unwrap();
let q = state.players[0].cities[0].queue.as_ref().unwrap();
assert!(matches!(q, mc_city::Queueable::Unit { .. }));
}
#[test]
fn queue_production_with_item_id_sets_item_queueable() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
state.players[0].cities.push(mc_city::CityState::starter());
let _ = apply_action(
&mut state,
0,
&PlayerAction::QueueProduction {
city_id: "0_0".into(),
item: "iron_sword".into(),
tile: None,
},
)
.unwrap();
let q = state.players[0].cities[0].queue.as_ref().unwrap();
assert!(matches!(q, mc_city::Queueable::Item { .. }));
}
#[test]
fn queue_production_unknown_city_returns_unknown_city_error() {
let mut state = GameState::default();
let err = apply_action(
&mut state,
0,
&PlayerAction::QueueProduction {
city_id: "99_99".into(),
item: "dwarf_warrior".into(),
tile: None,
},
)
.unwrap_err();
assert!(matches!(err, ActionError::UnknownCity { .. }));
}
#[test]
fn research_tech_sets_player_tech_state() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let _ = apply_action(
&mut state,
0,
&PlayerAction::ResearchTech {
tech_id: "bronze_working".into(),
},
)
.unwrap();
let pt = state.players[0].player_tech.as_ref().unwrap();
assert_eq!(pt.current_research(), Some("bronze_working"));
assert_eq!(pt.research_progress(), 0);
}
#[test]
fn research_tradition_sets_flat_field_on_player_state() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let _ = apply_action(
&mut state,
0,
&PlayerAction::ResearchTradition {
tradition_id: "ancestor_veneration".into(),
},
)
.unwrap();
assert_eq!(
state.players[0].researching_tradition,
"ancestor_veneration"
);
assert_eq!(state.players[0].culture_research_progress, 0);
}
#[test]
fn ransom_response_on_unknown_offer_returns_illegal_action() {
let mut state = GameState::default();
let err = apply_action(
&mut state,
0,
&PlayerAction::RespondToRansom {
offer_id: 999,
response: crate::action::RansomResponse::Accept,
},
)
.unwrap_err();
assert!(matches!(err, ActionError::IllegalAction { .. }));
}
// ── apply_ai_action (p2-68 Wave 2) ───────────────────────────────────
#[test]
fn ai_fortify_routes_through_apply_action_fortify() {
let mut state = empty_state_with_one_unit(7);
// First put a Founder so Fortify on a non-fortifyable unit doesn't
// matter — Fortify on any unit succeeds at the handler level.
let action = mc_ai::tactical::Action::Fortify { unit_id: 7 };
let _ = apply_ai_action(&mut state, 0, action).unwrap();
assert!(
state.players[0].units[0].is_fortified,
"Fortify must mark the unit as fortified"
);
}
#[test]
fn ai_move_routes_through_player_action_move() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let action = mc_ai::tactical::Action::MoveUnit {
unit_id: 1,
to_hex: (3, 0),
};
// No grid → process_move_requests rejects without panicking.
// The point of this test is that the AI action routed to the
// Move dispatch path; we accept either Ok (moved) or Err (rejected
// by movement subsystem), but NOT an UnknownUnit error.
let res = apply_ai_action(&mut state, 0, action);
match res {
Ok(_) => {}
Err(ActionError::IllegalAction { .. }) => {}
Err(ActionError::TargetInvalid { .. }) => {}
Err(e) => panic!("expected move-path result, got unexpected error: {e:?}"),
}
}
#[test]
fn ai_unknown_attack_target_returns_unknown_unit() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let action = mc_ai::tactical::Action::AttackTarget {
attacker_id: 1,
target_id: 999,
posture: None,
};
let err = apply_ai_action(&mut state, 0, action).unwrap_err();
assert!(matches!(err, ActionError::UnknownUnit { .. }));
}
#[test]
fn ai_assign_citizen_is_silent_no_op() {
let mut state = GameState::default();
let action = mc_ai::tactical::Action::AssignCitizen {
city_id: 0,
tile_hex: (1, 2),
};
// Must not abort the AI turn; returns empty events.
let events = apply_ai_action(&mut state, 0, action).unwrap();
assert!(events.is_empty(), "AssignCitizen v1 is silent no-op");
}
#[test]
fn end_turn_drives_ai_via_run_ai_turn_not_scripted_heuristic() {
// The Wave 4 swap deleted `run_scripted_ai_turn`. Confirm an
// EndTurn on a two-player state where the non-Claude slot has a
// single warrior produces an AiTurnCompleted event (proves the
// headless driver ran) AND advances `state.turn` to 1.
let mut state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 5, 5)]);
let events = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap();
assert_eq!(state.turn, 1);
let ai_completed = events
.iter()
.filter(|e| matches!(e, Event::AiTurnCompleted { player: 1, .. }))
.count();
assert_eq!(
ai_completed, 1,
"expected exactly one AiTurnCompleted event for slot 1; events={events:?}"
);
}
#[test]
fn seed_for_ai_turn_is_deterministic_and_slot_unique() {
assert_eq!(seed_for_ai_turn(7, 1), seed_for_ai_turn(7, 1));
assert_ne!(seed_for_ai_turn(7, 1), seed_for_ai_turn(7, 2));
assert_ne!(seed_for_ai_turn(7, 1), seed_for_ai_turn(8, 1));
}
#[test]
fn ai_siege_variants_are_silent_no_ops() {
// DeploySiege / PackSiege / Bombard have no PlayerAction wire
// representation yet — the applicator must accept them rather
// than abort the whole turn.
let mut state = GameState::default();
let _ = apply_ai_action(
&mut state,
0,
mc_ai::tactical::Action::DeploySiege { unit_id: 5 },
)
.unwrap();
let _ = apply_ai_action(
&mut state,
0,
mc_ai::tactical::Action::PackSiege { unit_id: 5 },
)
.unwrap();
let _ = apply_ai_action(
&mut state,
0,
mc_ai::tactical::Action::Bombard {
unit_id: 5,
target_hex: (1, 1),
indirect_fire: false,
},
)
.unwrap();
}
}