1992 lines
75 KiB
Rust
1992 lines
75 KiB
Rust
//! 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();
|
||
}
|
||
}
|