magicciv/src/simulator/crates/mc-player-api/src/dispatch.rs
Natalie 8e17594564 feat(@projects/@magic-civilization): 🌿 p3-29 (3) — surface FloraSuccession; step-1 event enrichment complete
Third + final p3-29 step-1 event. The ecology phase (uniform fn(&mut GameState) registry
signature, no event sink) buffers its flora-succession transitions into a transient
GameState.pending_flora_events; step() drains them into the TurnResult as
TurnEvent::FloraSuccession — single-source replacement for the GDScript turn's flora_succession
signal, avoiding a 40-call-site registry-signature cascade. Surfaced through all four TurnEvent
consumers + tested (step_drains_flora_buffer_into_flora_succession_events).

p3-29 step 1 DONE: the Rust turn now emits CityGrew + CityBordersExpanded + FloraSuccession —
the granular UI events the live game's GDScript turn emitted inline. Replay value now;
UI-parity ready for the swap (steps 3-5). Events-only → golden/combat unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:26:11 -04:00

3313 lines
133 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_state::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 => {
// Communications Phase 6 gate — a player who lost their
// capital on turn N must call `NameSeatOfPower` before
// ending turn N+1 (or any later turn while still in
// blackout). The auto-promote stall guard only fires when
// an AI / harness bypasses the validator and runs the
// processor directly. See `project_capital_blackout_design`.
if (player as usize) < state.players.len()
&& mc_comms::blackout::is_in_blackout(&state.comms, player)
&& !state.players[player as usize].cities.is_empty()
{
let blackout = state
.comms
.per_player
.get(&player)
.and_then(|pc| pc.blackout);
if let Some(b) = blackout {
if state.turn > b.began_turn {
return Err(ActionError::IllegalAction {
message: "end_turn: you must name a new seat of \
power before ending the turn"
.into(),
});
}
}
}
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,
} => build_improvement(state, unit_id, &improvement_id.0),
PlayerAction::CraftEquipment { unit_id, item_id } => {
craft_equipment(state, unit_id, item_id)
}
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)
}
// p2-59 — escort assign/release. The handler gates on link state and
// queues an `EscortRequest`; the link materialises when the processor
// drains `pending_escort_requests` inside `apply_end_turn`'s `step`
// (which now carries the authored `EncounterRates`).
PlayerAction::EscortAssign { unit_id } => {
invoke_unit_action(state, unit_id, ActionKind::EscortAssign)
}
PlayerAction::EscortRelease { unit_id } => {
invoke_unit_action(state, unit_id, ActionKind::EscortRelease)
}
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 } => {
// Communications Phase 4: peace offers travel by envelope.
// The shared `Relation` cell does NOT flip on dispatch
// (or delivery); the recipient must reply with an
// `AcceptPeace` action, which dispatches its own
// `TreatyAccept { agreement_kind: "peace" }` envelope and
// flips the relation on delivery.
crate::comms_dispatch::dispatch_peace_proposal(state, player, *to);
Ok(Vec::new())
}
// Communications Phase 4 — OpenBorders signing now travels by
// envelope. The bench-cheat (offer = sign immediately) is gone;
// an offer dispatches a `TreatyOffer { agreement_kind:
// "open_borders" }` envelope which signs the agreement at
// delivery. Accept / Reject likewise dispatch
// `TreatyAccept` / `TreatyDecline` envelopes.
//
// SharedMap retains the Phase 2 bench-cheat semantics per the
// Phase 4 brief ("SharedMap stays as-is, don't re-touch").
PlayerAction::OfferOpenBorders { to } => apply_offer_open_borders(state, player, *to),
PlayerAction::AcceptOpenBorders { from } => {
apply_accept_open_borders(state, player, *from)
}
PlayerAction::RejectOpenBorders { from } => {
apply_reject_open_borders(state, player, *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),
PlayerAction::NameSeatOfPower { city_id } => {
apply_name_seat_of_power(state, player, city_id)
}
}
}
/// Communications Phase 6 — handle `PlayerAction::NameSeatOfPower`.
///
/// Legal only when the player is currently in `mc_comms` blackout AND
/// owns at least one city AND the named city belongs to this player.
/// Parses the canonical `city_<player>_<idx>` id, sets
/// `capital_position` to that city's hex, clears the blackout overlay,
/// and emits `CapitalBlackoutEnded`.
fn apply_name_seat_of_power(
state: &mut GameState,
player: PlayerId,
city_id: &str,
) -> Result<Vec<Event>, ActionError> {
let pi = player as usize;
if pi >= state.players.len() {
return Err(ActionError::IllegalAction {
message: "name_seat_of_power: player slot out of range".into(),
});
}
if !mc_comms::blackout::is_in_blackout(&state.comms, player) {
return Err(ActionError::IllegalAction {
message: "name_seat_of_power: player is not in capital blackout".into(),
});
}
// Parse `city_<player>_<idx>` — must match the calling player and an
// index into the current `city_positions`.
let mut iter = city_id.splitn(3, '_');
let _ = iter.next();
let owner_str = iter.next().unwrap_or("");
let idx_str = iter.next().unwrap_or("");
let owner_idx: u8 = owner_str.parse().map_err(|_| ActionError::IllegalAction {
message: format!("name_seat_of_power: malformed city_id `{}`", city_id),
})?;
let idx: usize = idx_str.parse().map_err(|_| ActionError::IllegalAction {
message: format!("name_seat_of_power: malformed city_id `{}`", city_id),
})?;
if owner_idx != player {
return Err(ActionError::IllegalAction {
message: "name_seat_of_power: city does not belong to this player".into(),
});
}
let player_state = &mut state.players[pi];
if idx >= player_state.city_positions.len() {
return Err(ActionError::IllegalAction {
message: "name_seat_of_power: city index out of range".into(),
});
}
let pos = player_state.city_positions[idx];
player_state.capital_position = Some(pos);
let _ = mc_comms::blackout::end_blackout(&mut state.comms, player);
// Phase 6 — emit `CapitalBlackoutEnded` into the side-channel
// `pending_chronicle_json` buffer on `CommsState`. The next
// `run_end_of_turn_comms_passes` drains it into the replay
// archive. Stored as a JSON string so this crate (mc-player-api)
// doesn't need to thread a TurnEvent vector across the `apply_action`
// return type (which returns wire `Event`s, not chronicle ones).
let ended_event = mc_replay::TurnEvent::CapitalBlackoutEnded {
turn: state.turn,
player: mc_replay::ClanId(player as u32),
new_capital_city_id: mc_replay::CityName(city_id.to_string()),
};
if let Ok(json) = serde_json::to_string(&ended_event) {
state.comms.pending_chronicle_json.push(json);
}
Ok(Vec::new())
}
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: "round_end".into(), // p2-83: now driven by RoundDriver / round phase; legacy name for wire compat window (to be removed with consumers)
},
];
// 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.
// Stage 4 (multi-slot adapter) — skip every externally-driven slot,
// not just the one that called EndTurn. Without this, in a 5-learned
// vs 5-scripted FFA the harness's internal AI would still run for
// four of the learned slots and step on the Python-side decisions.
// `player` is always included in the skip set (it just ended its turn).
let external_slots = external_player_slots_from_env();
for ai_slot in 0..state.players.len() {
let ai_slot_u8: u8 = ai_slot as u8;
if ai_slot_u8 == player || external_slots.contains(&ai_slot_u8) {
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 mut processor = mc_turn::processor::TurnProcessor::new(u32::MAX);
// p2-59 — load the authored ambient-encounter rates so the persisted
// step drains `pending_escort_requests` (escort links) AND rolls fauna
// encounters. Before this the dispatch processor was built with
// `encounter_rates: None`, making both phases silent no-ops on the
// Claude-API / live path: escort assignment never materialised and
// ambient encounters never fired. Rail 2: the JSON file remains the
// single authored source; `load_authored_encounter_rates` bakes a
// build-time copy for this headless path (no GDScript DataLoader).
processor.load_authored_encounter_rates();
// p3-26 B3: the fresh-per-turn processor starts with an empty improvement
// yield table; repopulate it from the boot-loaded defs so completed
// improvements fold food/production into their owning cities.
for (id, def) in &state.improvement_defs {
processor.improvement_yield_table.insert(
id.clone(),
mc_turn::processor::ImprovementYieldEntry {
food: def.food,
production: def.production,
},
);
}
// Load the boot-loaded TechWeb so `process_science` auto-advances
// research (topological order) each turn. Without this the fresh
// per-turn processor has `tech_web_parsed: None` and research is frozen
// at 0 techs — units never leave tier-1 and AI-vs-AI stalemates. The
// JSON was already validated at boot by `GdGameState::set_tech_web_json`,
// so a parse error here is non-fatal (proceed research-less).
if !state.tech_web_json.is_empty() {
let _ = processor.set_tech_web_json(&state.tech_web_json);
}
if let Some(vc) = victory_config_from_env() {
processor.victory_config = Some(vc);
}
let mut result = processor.step(state);
// Communications Phase 6 — end-of-turn comms passes.
// Runs after the processor step (so `state.turn` is the new turn
// and `step_comms` evaluates deliveries against it). Order:
// 1. step_comms — drive Dispatched -> InTransit -> Delivered /
// Intercepted; emit EnvelopeDelivered/Intercepted/LinkSevered.
// 2. step_heartbeats — auto-spawn heartbeat envelopes per
// vision-share link whose interval elapsed.
// 3. evaluate_collapse — fold any active links whose missed
// count reached 2; emit VisionShareCollapsed.
// 4. beacon-tap pass — walk in-flight envelopes vs hostile
// beacon-tile occupancy; apply outcomes; emit EnvelopeTapped.
// 5. auto-promote stall guard — for any player still in blackout
// past `auto_promote_at_turn`, pick highest-pop city and
// promote, clearing the blackout; emits CapitalBlackoutEnded.
let comms_events = run_end_of_turn_comms_passes(state);
result.events_emitted.extend(comms_events);
// 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)
}
/// Communications Phase 6 — end-of-turn comms passes orchestrator.
///
/// Runs after the turn processor has stepped. Drives every comms
/// machinery whose source-of-truth lives as a pure function in
/// `mc-comms`:
///
/// 1. `step_comms_with_events` — delivery / interception resolution.
/// 2. `step_heartbeats` — auto-spawn heartbeat envelopes per vision-share
/// link whose interval elapsed.
/// 3. `evaluate_collapse` — flip `Active` → `CollapsedStale` links.
/// 4. Beacon-tap pass — minimal world-walk over in-flight envelopes
/// against hostile beacon-tile occupancy. Phase 6 ships a deterministic
/// no-RNG variant (rolls are derived from `(turn, envelope_id, beacon)`)
/// so headless tests are reproducible.
/// 5. Auto-promote stall guard — for any player still in blackout past
/// `auto_promote_at_turn`, pick highest-pop city via
/// `pick_auto_promote_target` and promote.
///
/// Returns the accumulated `mc_replay::TurnEvent` list so the caller can
/// extend `result.events_emitted` (and surface them through the replay
/// archive + GDScript dispatcher).
pub(crate) fn run_end_of_turn_comms_passes(
state: &mut GameState,
) -> Vec<mc_replay::TurnEvent> {
let mut events: Vec<mc_replay::TurnEvent> = Vec::new();
let turn_now = state.turn;
// 0. Drain the side-channel chronicle buffer (events emitted from
// non-end-of-turn paths such as `PlayerAction::NameSeatOfPower`).
let pending: Vec<String> = std::mem::take(&mut state.comms.pending_chronicle_json);
for json in pending {
if let Ok(ev) = serde_json::from_str::<mc_replay::TurnEvent>(&json) {
events.push(ev);
}
}
// 1. Drive envelopes through delivery / interception.
let _ = crate::comms_dispatch::step_comms_with_events(state, &mut events);
// 2. Heartbeat scheduler. Capture closure inputs first; the closure
// borrows state.comms immutably, so we can't dispatch from inside.
let ticks = {
let comms_ref = &state.comms;
mc_comms::heartbeat::step_heartbeats(comms_ref, turn_now, |p| {
mc_comms::blackout::is_in_blackout(comms_ref, p)
})
};
for tick in ticks {
// Spawn a heartbeat envelope through the dispatch path so it
// shares the route-resolution + storage code with every other
// envelope. Emits `EnvelopeDispatched` too — fine for the
// chronicle, dev-overlay filters heartbeat-flavoured kinds.
crate::comms_dispatch::dispatch_envelope_internal(
state,
tick.sender,
tick.recipient,
mc_comms::Payload::Heartbeat,
&mut events,
);
events.push(mc_replay::TurnEvent::HeartbeatSent {
turn: turn_now,
agreement_id: tick.agreement_id,
sender: mc_replay::ClanId(tick.sender as u32),
recipient: mc_replay::ClanId(tick.recipient as u32),
});
}
// 3. Collapse evaluation.
let collapsed = mc_comms::heartbeat::evaluate_collapse(&mut state.comms, turn_now);
for (agreement_id, parties, _missed) in collapsed {
events.push(mc_replay::TurnEvent::VisionShareCollapsed {
turn: turn_now,
agreement_id,
parties: [
mc_replay::ClanId(parties.0 as u32),
mc_replay::ClanId(parties.1 as u32),
],
reason: "missed_heartbeats".into(),
});
}
// 4. Beacon-tap pass. Minimal world-walk: iterate in-flight
// envelopes; for each, walk the planned path; for each tile, ask
// whether a beacon-tower improvement sits on it AND whether the
// occupier is hostile. Phase 6 keeps this conservative — the
// full per-improvement / per-occupancy index would belong on
// `mc-turn::spatial_index`. The current call drives a no-op pass
// on fresh saves (no beacons + no envelopes => empty input) and
// builds a populated `BeaconTapInput` once the bench scenario
// includes both.
run_beacon_tap_pass(state, turn_now, &mut events);
// 5. Auto-promote stall guard. Iterates every player; for each in
// blackout past `auto_promote_at_turn`, picks the highest-pop
// surviving city and promotes.
run_auto_promote_stall_guard(state, turn_now, &mut events);
// Drain turn-scoped tap log for the next turn.
mc_comms::beacon_tap::clear_turn_scoped_taps(&mut state.comms);
events
}
/// Phase 6 minimal beacon-tap pass. See `run_end_of_turn_comms_passes`
/// step 4 for context.
fn run_beacon_tap_pass(
state: &mut GameState,
turn_now: u32,
events: &mut Vec<mc_replay::TurnEvent>,
) {
use mc_comms::beacon_tap::{
apply_tap_outcomes, evaluate_beacon_taps, BeaconTapInput,
};
// Build a fast (col, row) -> occupying-player lookup so the
// path-walk can answer "is this tile hostile occupied?" without
// an O(P * U) re-scan per tile.
let mut occupancy: std::collections::BTreeMap<(i32, i32), u8> =
std::collections::BTreeMap::new();
for (pi, p) in state.players.iter().enumerate() {
for u in &p.units {
occupancy.insert((u.col as i32, u.row as i32), pi as u8);
}
}
// Build a fast (col, row) -> "is beacon_tower" lookup from
// tile_improvements. Treat "beacon_tower" string match as the
// signal; this matches Phase 3's improvement-id scheme.
let mut beacons: std::collections::BTreeSet<(i32, i32)> =
std::collections::BTreeSet::new();
for (key, imp) in state.tile_improvements.iter() {
if imp.id == "beacon_tower" {
// tile_improvements is keyed (u16, u16); widen to i32.
beacons.insert((key.0 as i32, key.1 as i32));
}
}
// Iterate envelopes; build (envelope_id, BeaconTapInput) tuples
// for each in-flight envelope that crosses at least one hostile
// beacon. The `rolls` per-input are derived deterministically so
// tests are reproducible.
let envelope_snapshots: Vec<(mc_comms::EnvelopeId, mc_comms::Envelope)> = state
.comms
.envelopes
.iter()
.filter(|(_, e)| {
matches!(
e.status,
mc_comms::EnvelopeStatus::Dispatched
| mc_comms::EnvelopeStatus::InTransit
)
})
.map(|(id, e)| (*id, e.clone()))
.collect();
for (id, env) in envelope_snapshots {
if beacons.is_empty() {
break;
}
let mut hostile_beacons: Vec<((i32, i32), u8)> = Vec::new();
// Walk the planned path; collect hostile-occupied beacons.
for tile in env.route.planned_path.iter() {
if !beacons.contains(tile) {
continue;
}
let Some(&occupier) = occupancy.get(tile) else {
continue;
};
// Friendly-occupied beacons don't tap own/sender's mail.
if occupier == env.sender {
continue;
}
hostile_beacons.push((*tile, occupier));
}
if hostile_beacons.is_empty() {
continue;
}
// Deterministic rolls: PRNG by (turn, envelope_id, beacon-idx).
let rolls: Vec<f32> = (0..hostile_beacons.len())
.map(|idx| deterministic_roll(turn_now, id.0, idx as u32))
.collect();
let input = BeaconTapInput {
envelope_id: id,
sender: env.sender,
recipient: env.recipient,
sender_has_adamantine_echo: false,
recipient_has_adamantine_echo: false,
hostile_beacons,
payload_kind: env.payload.kind().to_string(),
turn: turn_now,
};
let outcomes = evaluate_beacon_taps(&input, &rolls);
apply_tap_outcomes(&mut state.comms, &outcomes);
for o in &outcomes {
events.push(mc_replay::TurnEvent::EnvelopeTapped {
turn: turn_now,
envelope_id: o.envelope_id.0,
sender: mc_replay::ClanId(env.sender as u32),
recipient: mc_replay::ClanId(env.recipient as u32),
intercepting_player: mc_replay::ClanId(o.intercepting_player as u32),
payload_kind: o.payload_kind.clone(),
beacon_hex: mc_replay::TileCoord::new(o.beacon_hex.0, o.beacon_hex.1),
});
}
}
}
/// PCG-style deterministic roll in `[0.0, 1.0)` for the beacon-tap pass.
/// Pure function of `(turn, envelope_id, beacon_index)` — same inputs
/// always give the same output. No global RNG state.
fn deterministic_roll(turn: u32, envelope_id: u32, beacon_idx: u32) -> f32 {
let mut x: u64 = (turn as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(envelope_id as u64);
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 30;
x = x.wrapping_add(beacon_idx as u64);
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 27;
// 24-bit mantissa cap for f32.
let frac = (x & 0x00FF_FFFF) as f32 / (1u32 << 24) as f32;
frac
}
/// Phase 6 auto-promote stall guard. See `run_end_of_turn_comms_passes`
/// step 5.
fn run_auto_promote_stall_guard(
state: &mut GameState,
turn_now: u32,
events: &mut Vec<mc_replay::TurnEvent>,
) {
let player_count = state.players.len();
for pi in 0..player_count {
let player_u8 = pi as u8;
if !mc_comms::blackout::should_auto_promote(&state.comms, player_u8, turn_now) {
continue;
}
// Build the (city_id, pop) list. The Phase 6 city_id encoding is
// `city_<player>_<idx>` — matching the synthesised scheme other
// chronicle events use.
let candidates: Vec<(String, u32)> = {
let p = &state.players[pi];
p.cities
.iter()
.enumerate()
.map(|(idx, c)| (format!("city_{}_{}", pi, idx), c.population))
.collect()
};
if candidates.is_empty() {
// No surviving cities — blackout stays; elimination handles
// this case elsewhere.
continue;
}
let Some(chosen_id) = mc_comms::blackout::pick_auto_promote_target(&candidates)
else {
continue;
};
// Parse the chosen id back to an index. Since we generated the
// list, this should always succeed; guard for robustness.
let idx_str = chosen_id.rsplit('_').next().unwrap_or("");
let Ok(idx) = idx_str.parse::<usize>() else {
continue;
};
let player_state = &mut state.players[pi];
if idx >= player_state.city_positions.len() {
continue;
}
let pos = player_state.city_positions[idx];
player_state.capital_position = Some(pos);
let _ = mc_comms::blackout::end_blackout(&mut state.comms, player_u8);
events.push(mc_replay::TurnEvent::CapitalBlackoutEnded {
turn: turn_now,
player: mc_replay::ClanId(pi as u32),
new_capital_city_id: mc_replay::CityName(chosen_id),
});
}
}
/// 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.
/// Read `CP_VICTORY_MODE` from the environment and return a matching
/// `VictoryConfig`. Returns `None` when the var is unset or empty —
/// preserving the legacy simple-city-count fallback in `TurnProcessor`
/// for existing harness consumers. Recognised values:
///
/// * `"domination"` — capture-all-capitals only. Other victory paths
/// are disabled (unreachable thresholds, empty science chain). The
/// game ends only via domination (capital capture) or LastSurvivor
/// (opponent has no cities AND no capital). No turn cap — callers
/// enforce their own.
///
/// Unknown values are ignored (return `None`) so a typo doesn't silently
/// alter behaviour mid-training.
/// Stage 4 (multi-slot adapter) — slots driven by the external client.
///
/// Read from `CP_PLAYER_SLOTS` (comma-separated, e.g. `"0,2,3"`) and
/// fall back to `CP_PLAYER_SLOT` (single value) for back-compat with
/// the single-slot wire. Returned as a `Vec<u8>` (small N: ≤ MAX_PLAYERS);
/// callers test membership via linear scan.
///
/// Empty result = no slots are externally driven. `apply_end_turn` then
/// retains the original "skip only the calling player" semantics so
/// fixtures and unit tests without env vars set keep working unchanged.
fn external_player_slots_from_env() -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
if let Ok(plural) = std::env::var("CP_PLAYER_SLOTS") {
for piece in plural.split(',') {
let trimmed = piece.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(n) = trimmed.parse::<u8>() {
if !out.contains(&n) {
out.push(n);
}
}
}
}
if out.is_empty() {
if let Ok(single) = std::env::var("CP_PLAYER_SLOT") {
if let Ok(n) = single.trim().parse::<u8>() {
out.push(n);
}
}
}
out
}
fn victory_config_from_env() -> Option<mc_turn::VictoryConfig> {
let mode = std::env::var("CP_VICTORY_MODE").ok()?;
match mode.as_str() {
"domination" => Some(mc_turn::VictoryConfig {
city_count_threshold: usize::MAX,
gold_threshold: i64::MAX,
culture_threshold: i64::MAX,
science_techs_required: Vec::new(),
science_cost_base: 0,
domination_requires_all_capitals: true,
min_domination_turn: 0,
turn_limit: None,
}),
_ => None,
}
}
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,
});
}
// p2-67 Bug 4: surface non-wonder building completions emitted
// from `process_city_production`'s `Queueable::Item` branch.
// The wire `Event::CityBuildingCompleted` already exists for
// the `apply_rush_buy` path; this lights it up for natural
// completions too.
mc_replay::TurnEvent::CityBuildingCompleted {
city,
building_id,
..
} => {
out.push(Event::CityBuildingCompleted {
city_id: city.0.clone(),
building_id: building_id.clone(),
});
}
mc_replay::TurnEvent::CityFounded { clan, hex, .. } => {
let position: crate::WireHex = [hex.q, hex.r];
out.push(Event::CityFounded {
city_id: format!("city_{}_{}_{}", clan.0, hex.q, hex.r),
owner: clan.0 as PlayerId,
position,
});
}
mc_replay::TurnEvent::CityCaptured {
attacker,
defender,
hex,
..
} => {
out.push(Event::CityCaptured {
city_id: format!("city_{}_{}", hex.q, hex.r),
old_owner: defender.0 as PlayerId,
new_owner: attacker.0 as PlayerId,
});
}
// p2-replay-followup: every unit-creation chronicle entry
// surfaces as `Event::UnitCreated` on the wire. When the
// spawn was attributed to a city (production drain), we
// also emit `Event::CityUnitCompleted` so adapters that
// only listen for queue completions still see the event.
mc_replay::TurnEvent::UnitCreated {
clan,
unit_id,
unit_kind,
hex,
city,
..
} => {
let position: crate::WireHex = [hex.q, hex.r];
out.push(Event::UnitCreated {
unit_id: format!("u_{unit_id}"),
owner: clan.0 as PlayerId,
position,
});
if let Some(city_name) = city {
out.push(Event::CityUnitCompleted {
city_id: city_name.0.clone(),
unit_id: unit_kind.0.clone(),
});
}
}
// p2-replay-followup: capture is a new-unit appearance from
// the captor's POV; surface as `Event::UnitCreated` owned by
// the captor. The underlying `units_captured` log carries
// the full prior-owner detail when adapters need it.
mc_replay::TurnEvent::UnitCaptured {
captor,
unit_id,
hex,
..
} => {
let position: crate::WireHex = [hex.q, hex.r];
out.push(Event::UnitCreated {
unit_id: format!("u_{unit_id}"),
owner: captor.0 as PlayerId,
position,
});
}
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,
});
}
}
// p2-67 Bug 3: surface PvP kills as `Event::UnitDestroyed`.
// `killer_unit_id` correlation is deferred — `UnitKilled` only
// carries the killing-clan id, not the specific attacker unit.
mc_replay::TurnEvent::UnitKilled { unit_id, .. } => {
out.push(Event::UnitDestroyed {
unit_id: format!("u_{unit_id}"),
killer_unit_id: None,
});
}
// 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::WarDeclared { .. }
| mc_replay::TurnEvent::PeaceSigned { .. }
| mc_replay::TurnEvent::EraEntered { .. }
| mc_replay::TurnEvent::LeaderChanged { .. }
| mc_replay::TurnEvent::ClanEliminated { .. }
| mc_replay::TurnEvent::UnitRansomOffered { .. }
| mc_replay::TurnEvent::CivilianDestroyed { .. }
// Communications Phase 1 events (mc-replay WIP): no wire
// surface yet; the adapter consumes these via a side channel
// when that surface lands. Drop them here to keep the
// exhaustive match closed against TurnEvent.
| mc_replay::TurnEvent::PlayerDiscovered { .. }
| mc_replay::TurnEvent::CitySpotted { .. }
| mc_replay::TurnEvent::UnitSpotted { .. }
// Communications Phase 3 events: no wire surface yet; wire
// adapter consumes these via the same side channel as the
// Phase 1 events. Drop here to keep the match exhaustive.
| mc_replay::TurnEvent::CapitalBlackoutBegan { .. }
| mc_replay::TurnEvent::CapitalBlackoutEnded { .. }
| mc_replay::TurnEvent::EnvelopeTapped { .. }
| mc_replay::TurnEvent::HeartbeatSent { .. }
| mc_replay::TurnEvent::HeartbeatMissed { .. }
| mc_replay::TurnEvent::VisionShareCollapsed { .. }
| mc_replay::TurnEvent::VisionShareRestored { .. }
// Communications Phase 6 envelope-flow / link-flow events:
// no wire surface yet, same drop-here pattern.
| mc_replay::TurnEvent::EnvelopeDispatched { .. }
| mc_replay::TurnEvent::EnvelopeDelivered { .. }
| mc_replay::TurnEvent::EnvelopeIntercepted { .. }
| mc_replay::TurnEvent::LinkSevered { .. }
| mc_replay::TurnEvent::LinkRestored { .. }
// p3-29: surfaced for replay + the live UI (city growth / border
// expansion), not the wire protocol — drop here to keep it exhaustive.
| mc_replay::TurnEvent::CityGrew { .. }
| mc_replay::TurnEvent::CityBordersExpanded { .. }
| mc_replay::TurnEvent::FloraSuccession { .. } => {}
}
}
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;
}
// p1-60 AI fairness: project through the active player's vision so
// the AI sees the same fog the human does. `CP_OMNISCIENT=1` env
// (read by the harness once at boot) is honoured by passing `None`
// — preserved for debug/repro flows.
let vision_state = mc_vision::compute_vision(
state,
&mc_vision::VisionCatalog::default(),
None,
);
let pv = vision_state.for_player(ai_slot);
let mut tactical = crate::projection::project_tactical_with_vision(state, ai_slot, pv);
tactical.current_player = ai_slot;
let weights = state.players[pi].scoring_weights.clone();
let seed = seed_for_ai_turn(state.turn, ai_slot);
// Stage 3 (mod system) — route through the controller registry
// instead of hardcoding the scripted MCTS path. Empty / unknown
// ids fall back to `DEFAULT_CONTROLLER_ID` inside
// `drive_controller_turn` so legacy fixtures without
// `controller_id` set keep working unchanged.
let controller_id = state.players[pi].controller_id.clone();
// p1-29f — `learned:*` slots run the trained ONNX policy via a re-observing
// view-loop (mirroring the training harness `view`/`act` loop), NOT the
// one-shot tactical `decide_turn`. Route them to `drive_learned_slot`.
if crate::learned::is_learned_controller(&controller_id) {
return drive_learned_slot(state, ai_slot);
}
// p1-29h — borrow the slot's persistent tactical memory `&mut` so the
// army-level target-lock + commitment hysteresis survives this turn's
// `TacticalState` snapshot (which is rebuilt fresh every turn). `tactical`,
// `weights`, and `controller_id` are all owned/cloned by here, so no
// borrow of `state` is live and this disjoint field borrow is sound.
let mut memory = std::mem::take(&mut state.players[pi].tactical_memory);
let actions = crate::controllers::drive_controller_turn(
&controller_id,
&tactical,
ai_slot,
&weights,
seed,
&mut memory,
);
state.players[pi].tactical_memory = memory;
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
}
/// Hard cap on actions applied for one learned slot's turn. The policy was
/// trained with a per-episode step budget; a runaway loop (e.g. the policy
/// repeatedly picking a no-op-equivalent legal action that never advances the
/// turn) must terminate. 256 covers any legitimate turn for a duel-scale game
/// (max 16 units * a few orders + per-city builds) with wide margin.
const LEARNED_MAX_ACTIONS_PER_TURN: u32 = 256;
/// Drive one turn for a `learned:*` slot by running the trained ONNX policy
/// through the same `project_view -> decide -> apply_action -> re-project`
/// loop the policy trained against (p1-29f). Applies via [`apply_action`] —
/// the path training used for the policy's OWN actions (the harness
/// `apply_action_json`) — NOT `apply_ai_action`, so re-projected observations
/// match training. Returns the number of `PlayerAction`s applied (excluding
/// the terminal `end_turn`, which `apply_end_turn` issues on the dispatcher's
/// behalf — we stop the loop instead of applying it here to avoid recursing
/// the AI driver).
fn drive_learned_slot(state: &mut GameState, ai_slot: u8) -> u32 {
// p1-29k — thin wrapper over the recording variant; the player-API world
// discards the applied-action log (behaviour-identical to the pre-split
// loop). The autoplay surface uses `drive_learned_slot_recording` directly
// so its GDScript reconciler can replay the policy's chosen actions.
drive_learned_slot_recording(state, ai_slot).0
}
/// p1-29k — the learned-slot policy loop, returning the ordered list of
/// `PlayerAction`s it actually applied (the terminal `end_turn`/`noop` that
/// stops the loop is NOT included — it is not an applied mutation). This is
/// the single source of truth for the loop body; `drive_learned_slot` calls
/// it and discards the log, so the player-API world is unchanged. The
/// autoplay surface (`GdGameState::run_learned_slot`) consumes the log to
/// reconcile the Rust post-turn state back into its GDScript entities.
///
/// `.0` is the count applied (== `log.len()`); `.1` is the log. Both are
/// returned so callers that only want the count avoid a `.len()`.
pub fn drive_learned_slot_recording(
state: &mut GameState,
ai_slot: u8,
) -> (u32, Vec<crate::action::PlayerAction>) {
let pi = ai_slot as usize;
if pi >= state.players.len() {
return (0, Vec::new());
}
let net = match crate::learned::shared_learned_policy() {
Some(net) => net,
None => return (0, Vec::new()), // artifact unavailable — slot passes its turn.
};
// Deployment temperature for the learned policy. Default 0.0 → argmax,
// byte-identical to the shipped deterministic path (no-env behaviour
// unchanged). A non-zero `MC_LEARNED_TEMPERATURE` samples from the masked
// softmax: the trained policy wins by spreading mass around its argmax
// (which over-commits to `end_turn`), so a deployment temperature
// re-injects that winning exploration — see `learned::masked_sample` and
// the `ai-production.md` difficulty lever. Per-action seeds derive from
// the per-turn `(turn, slot)` seed mixed with the loop index, so sampling
// stays a pure function of state (reproducibility rail preserved).
let temperature: f32 = std::env::var("MC_LEARNED_TEMPERATURE")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|t| t.is_finite() && *t >= 0.0)
.unwrap_or(0.0);
let turn_seed = seed_for_ai_turn(state.turn, ai_slot);
let mut applied: u32 = 0;
let mut log: Vec<crate::action::PlayerAction> = Vec::new();
for action_idx in 0..LEARNED_MAX_ACTIONS_PER_TURN {
// Fog-aware projection, matching `drive_ai_slot` and the training
// harness default (`CP_OMNISCIENT=0`).
let vision_state =
mc_vision::compute_vision(state, &mc_vision::VisionCatalog::default(), None);
let pv = match vision_state.for_player(ai_slot) {
Some(pv) => pv,
None => break, // no vision for this slot — nothing to decide.
};
let view = crate::projection::project_view_with_vision(state, ai_slot, false, pv);
// Distinct per-action seed so successive draws within a turn differ
// (otherwise temperature sampling would redraw the same action).
let action_seed = turn_seed.wrapping_add((action_idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
let decision = match crate::learned::decide_action_with_temperature(
net, &view, temperature, action_seed,
) {
Ok(d) => d,
Err(_) => break,
};
match decision.action {
// The policy chose to end its turn (or has no legal action). Stop;
// `apply_end_turn` advances the slot rotation for us.
crate::action::PlayerAction::EndTurn
| crate::action::PlayerAction::Noop => break,
action => match apply_action(state, ai_slot, &action) {
Ok(_) => {
applied += 1;
log.push(action);
}
// A rejected action with no state change would loop forever —
// stop the turn rather than spin.
Err(_) => break,
},
}
}
(applied, log)
}
/// 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 } => {
// `city_id` is the per-player city index from the tactical
// projection (`TacticalCity.id = c_idx`). The dispatch resolver
// (`find_city_indices`) expects the projector wire id
// `"{player}_{c_idx}"`, so build it here — a bare index fails
// `UnknownCity` and silently drops every AI production decision.
let pa = PlayerAction::QueueProduction {
city_id: format!("{}_{}", player, city_id),
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: format!("{}_{}", player, city_id),
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)
}
AiAction::DeclareWar { target } => {
// p3-16: route the AI's war-dec through the same courier dispatch
// the human uses (PlayerAction::DeclareWar → apply_declare_war →
// comms_dispatch::dispatch_war_declaration).
let pa = PlayerAction::DeclareWar { on: target };
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
}
/// Stage 6.1.6 — translate a tactical AI [`mc_ai::tactical::Action`] into
/// its [`PlayerAction`] wire form WITHOUT applying it.
///
/// This is the pure, side-effect-free counterpart of [`apply_ai_action`]:
/// it builds exactly the same `PlayerAction` each `apply_ai_action` arm
/// would dispatch, but stops short of calling `apply_action`. Used by
/// [`suggest_actions`] to expose "what would the scripted AI do" over the
/// wire for the behavioural-cloning recorder.
///
/// Returns `None` for AI-action variants with no `PlayerAction` analogue
/// (`AssignCitizen`, `DeploySiege`, `PackSiege`, `Bombard`) — those are
/// silent no-ops in `apply_ai_action`, so dropping them from the
/// suggested chain does not change how the game state would evolve.
fn ai_action_to_player_action(
state: &GameState,
slot: PlayerId,
action: &mc_ai::tactical::Action,
) -> Option<PlayerAction> {
use mc_ai::tactical::Action as AiAction;
match action {
AiAction::MoveUnit { unit_id, to_hex } => Some(PlayerAction::Move {
unit_id: unit_id.to_string(),
to: [to_hex.0, to_hex.1],
}),
AiAction::AttackTarget { attacker_id, target_id, .. } => {
// Mirror apply_ai_action: resolve target_id → hex. If the
// target unit is gone, the action is not representable —
// skip it (apply_ai_action would surface UnknownUnit).
let target_hex = locate_unit_hex(state, *target_id)?;
Some(PlayerAction::Attack {
unit_id: attacker_id.to_string(),
target: [target_hex.0, target_hex.1],
})
}
AiAction::Fortify { unit_id } => Some(PlayerAction::Fortify {
unit_id: unit_id.to_string(),
}),
// Heal maps to Skip, exactly as in apply_ai_action.
AiAction::Heal { unit_id } => Some(PlayerAction::Skip {
unit_id: unit_id.to_string(),
}),
AiAction::FoundCity { settler_id, .. } => Some(PlayerAction::FoundCity {
unit_id: settler_id.to_string(),
}),
AiAction::SetProduction { city_id, item_id } => {
// `city_id` is the per-player city index (`TacticalCity.id`);
// emit the projector wire id `"{slot}_{c_idx}"` so the action
// round-trips through `act` / `find_city_indices` (a bare index
// fails `UnknownCity`). See `ai_action_to_player_action` twin
// in `apply_ai_action`.
Some(PlayerAction::QueueProduction {
city_id: format!("{}_{}", slot, city_id),
item: item_id.clone(),
tile: None,
})
}
AiAction::EnqueueBuild { city_id, item_id, .. } => {
Some(PlayerAction::QueueProduction {
city_id: format!("{}_{}", slot, city_id),
item: item_id.clone(),
tile: None,
})
}
// Scout maps to Move, exactly as in apply_ai_action.
AiAction::Scout { unit_id, to_hex } => Some(PlayerAction::Move {
unit_id: unit_id.to_string(),
to: [to_hex.0, to_hex.1],
}),
AiAction::IssuePatrol { unit_id, waypoints } => {
Some(PlayerAction::IssuePatrol {
unit_id: unit_id.to_string(),
waypoints: waypoints.iter().map(|(c, r)| [*c, *r]).collect(),
})
}
AiAction::PromotionPicked { unit_id, promotion_id } => {
Some(PlayerAction::Promote(crate::action::PromotionPick {
unit_id: unit_id.to_string(),
promotion_id: promotion_id.clone(),
}))
}
AiAction::DeclareWar { target } => Some(PlayerAction::DeclareWar { on: *target }),
// No PlayerAction analogue — silent no-ops in apply_ai_action.
AiAction::AssignCitizen { .. }
| AiAction::DeploySiege { .. }
| AiAction::PackSiege { .. }
| AiAction::Bombard { .. } => None,
}
}
/// Stage 6.1.6 — compute the scripted controller's action chain for
/// `slot` against the CURRENT state, WITHOUT applying any action or
/// advancing the turn.
///
/// This is the read-only sibling of [`drive_ai_slot`]: it runs the exact
/// same projection + controller pipeline (`compute_vision` →
/// `project_tactical_with_vision` → `drive_controller_turn`) so the
/// suggestion is identical to what the engine would actually play for
/// that slot — but it returns the action chain as `PlayerAction`s
/// instead of mutating `GameState`.
///
/// Takes `&GameState` (shared, not `&mut`): every call in the pipeline
/// is read-only. Determinism follows from `seed_for_ai_turn(turn, slot)`
/// being a pure function — calling `suggest_actions` twice on the same
/// state returns identical results and never touches the state.
pub fn suggest_actions(state: &GameState, slot: PlayerId) -> Vec<PlayerAction> {
let pi: usize = slot as usize;
if pi >= state.players.len() {
return Vec::new();
}
// Mirror drive_ai_slot exactly: project through the slot's own
// vision so the suggestion is fog-consistent with what an AI turn
// would see.
let vision_state =
mc_vision::compute_vision(state, &mc_vision::VisionCatalog::default(), None);
let pv = vision_state.for_player(slot);
let mut tactical =
crate::projection::project_tactical_with_vision(state, slot, pv);
tactical.current_player = slot;
let weights = state.players[pi].scoring_weights.clone();
let seed = seed_for_ai_turn(state.turn, slot);
let controller_id = state.players[pi].controller_id.clone();
// p1-29h — `suggest_actions` is read-only (`&GameState`), so probe with a
// CLONE of the slot's tactical memory: the suggestion reflects the current
// commitment/lock state without mutating it (a suggestion must not advance
// the hysteresis timer). Determinism holds because the clone starts from
// the same persisted state every call.
let mut memory = state.players[pi].tactical_memory.clone();
let actions = crate::controllers::drive_controller_turn(
&controller_id,
&tactical,
slot,
&weights,
seed,
&mut memory,
);
actions
.iter()
.filter_map(|a| ai_action_to_player_action(state, slot, a))
.collect()
}
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(),
})
}
/// p3-26 B3: begin a tile improvement on the worker's hex. Validates via the
/// action-handler gate (worker-keyword / terrain), then queues a
/// `PendingImprovement` credited to the owning city (the player's city whose
/// `owned_tiles` contains the worker's hex, else the first city). The build-tick
/// p3-26 B6b: craft + equip an item onto a unit. Looks up the item def, verifies the
/// player can afford its raw `materials` (from `strategic_ledger` — the refined outputs
/// B6a produces), consumes them, and pushes an `EquippedItem` onto the unit (so the combat
/// path adds its attack/defense). Rejects on unknown item or insufficient materials.
fn craft_equipment(
state: &mut GameState,
unit_id: &str,
item_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)?;
let def = match state.item_combat.get(item_id) {
Some(d) => d.clone(),
None => {
return Err(ActionError::IllegalAction {
message: format!("unknown item: {item_id}"),
})
}
};
let player = &mut state.players[player_idx];
// Affordability check (all materials) before consuming any.
for (res, amt) in &def.materials {
if player.strategic_ledger.get(res).copied().unwrap_or(0) < *amt {
return Err(ActionError::IllegalAction {
message: format!("insufficient {res} to craft {item_id}"),
});
}
}
for (res, amt) in &def.materials {
let have = player.strategic_ledger.entry(res.clone()).or_insert(0);
*have = have.saturating_sub(*amt);
}
player.units[unit_idx].equipped.push(mc_items::EquippedItem {
item_id: item_id.to_string(),
category: def.category.clone(),
charges_remaining: 0,
triggers_in_combat: false,
});
Ok(Vec::new())
}
/// p3-26 B3: begin a tile improvement on the worker's hex. Validates via the
/// action-handler gate (worker-keyword / terrain), then queues a
/// `PendingImprovement` credited to the owning city (the player's city whose
/// `owned_tiles` contains the worker's hex, else the first city). The build-tick
/// phase completes it after `build_turns` and folds the yields in. No-op if the
/// player has no city to credit or the improvement id is unknown (build_turns
/// defaults to 1).
fn build_improvement(
state: &mut GameState,
unit_id: &str,
improvement_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)?;
action_handlers::invoke(state, player_idx, unit_idx, ActionKind::BuildImprovement).map_err(
|e| ActionError::IllegalAction {
message: format!("{e}"),
},
)?;
let (col, row) = {
let u = &state.players[player_idx].units[unit_idx];
(u.col, u.row)
};
let turns = state
.improvement_defs
.get(improvement_id)
.map(|d| d.build_turns)
.unwrap_or(1)
.max(1) as i32;
let player = &mut state.players[player_idx];
let city_idx = match player
.cities
.iter()
.position(|c| c.owned_tiles.contains(&(col, row)))
{
Some(i) => i,
None if !player.cities.is_empty() => 0,
None => return Ok(Vec::new()),
};
player
.pending_improvements
.push(mc_state::game_state::PendingImprovement {
col,
row,
improvement_id: improvement_id.to_string(),
city_idx,
turns_remaining: turns,
});
Ok(Vec::new())
}
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_state::game_state::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"),
});
}
// Communications Phase 2: war declaration travels by envelope. The
// sender's `pending_war_dec` overlay flips at dispatch so the
// sender's strategic-war predicates unlock immediately; the shared
// `Relation` cell flips on envelope delivery (or on first combat,
// whichever comes first — the combat resolver already promotes the
// shared cell via `advance_relations` when damage is dealt). This
// is what makes the "surprise dawn" attack work: sender attacks
// immediately; defender's UNDER-ATTACK toast fires on damage; but
// recipient's strategic war-mode is gated on the envelope arriving.
crate::comms_dispatch::dispatch_war_declaration(state, 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"),
});
}
// Communications Phase 4: peace acceptance travels by envelope. The
// shared `Relation` cell flips on envelope delivery via
// `apply_treaty_accept_delivery` (agreement_kind = "peace"), not at
// dispatch. Interception leaves the war state intact and the
// accepter free to re-issue.
crate::comms_dispatch::dispatch_treaty_accept(state, player, from, "peace");
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>`.
// Classify the requested id as Unit vs Item (building) by consulting the
// authoritative runtime units catalog — a known unit id → `Unit`, anything
// else → `Item`. This replaces a fragile `starts_with("dwarf_")` prefix
// heuristic that misclassified every non-`dwarf_`-prefixed unit (cavalry,
// dragoon, siege, walkers, wild creatures) as a building, so `try_spawn_unit`
// skipped it and the city fell back to the hardcoded tier-1 spawn (Rail 2).
// Fallback: when no catalog is loaded (bare bench fixtures), retain the
// legacy prefix so those tests keep producing units.
use mc_core::ids::UnitId;
let is_unit = if state.units_catalog.is_empty() {
item.starts_with("dwarf_")
} else {
state.units_catalog.get(item).is_some()
};
let queueable: mc_city::Queueable = if is_unit {
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(),
});
}
// Communications Phase 4: Open Borders offers travel by envelope.
// The agreement is signed at delivery (see
// `comms_dispatch::apply_treaty_offer_delivery`), not at dispatch.
// Interception drops the offer with zero diplomatic fallout.
crate::comms_dispatch::dispatch_treaty_offer(state, from, to, "open_borders");
Ok(Vec::new())
}
fn apply_accept_open_borders(
state: &mut GameState,
accepter: PlayerId,
offerer: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (offerer as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("open_borders offerer {offerer} out of range"),
});
}
if accepter == offerer {
return Err(ActionError::IllegalAction {
message: "cannot accept open borders from self".into(),
});
}
// Communications Phase 4: the accept reply is itself an envelope.
// Phase 2 bench-cheat semantics signed the agreement at offer time,
// so today the recipient's accept has nothing left to sign; the
// envelope still rides the wire so projection / replay surfaces the
// round-trip. When the bench-cheat is dropped in favour of real
// pending-offer staging, the accept-delivery handler will be where
// the agreement push moves to.
crate::comms_dispatch::dispatch_treaty_accept(state, accepter, offerer, "open_borders");
Ok(Vec::new())
}
fn apply_reject_open_borders(
state: &mut GameState,
rejecter: PlayerId,
offerer: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (offerer as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("open_borders offerer {offerer} out of range"),
});
}
if rejecter == offerer {
return Err(ActionError::IllegalAction {
message: "cannot reject open borders from self".into(),
});
}
crate::comms_dispatch::dispatch_treaty_decline(state, rejecter, offerer, "open_borders");
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_state::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
}
#[test]
fn build_improvement_queues_pending_on_owning_city() {
use mc_city::CityState;
use mc_state::game_state::ImprovementDef;
let mut state = make_state_with_units(vec![(0, 1, 5, 5)]);
let mut city = CityState::default();
city.owned_tiles = vec![(5, 5)];
state.players[0].cities.push(city);
state.players[0].city_improvements = vec![vec![]];
state.improvement_defs.insert(
"farm".into(),
ImprovementDef {
build_turns: 3,
food: 2,
production: 0,
},
);
build_improvement(&mut state, "1", "farm").expect("build queues a pending improvement");
let pending = &state.players[0].pending_improvements;
assert_eq!(pending.len(), 1, "one pending improvement queued");
assert_eq!(pending[0].improvement_id, "farm");
assert_eq!(pending[0].city_idx, 0, "credited to the owning city");
assert_eq!(pending[0].turns_remaining, 3, "turns_remaining from build_turns def");
}
fn sword_def() -> mc_state::game_state::ItemCombatBonus {
mc_state::game_state::ItemCombatBonus {
attack: 3,
defense: 0,
category: "weapon".into(),
materials: vec![("iron_ore".to_string(), 2)],
}
}
#[test]
fn craft_equipment_consumes_materials_and_equips() {
let mut state = make_state_with_units(vec![(0, 1, 5, 5)]);
state.players[0].strategic_ledger = [("iron_ore".to_string(), 5u32)].into_iter().collect();
state.item_combat.insert("bronze_sword".to_string(), sword_def());
craft_equipment(&mut state, "1", "bronze_sword").expect("craft succeeds");
assert_eq!(
state.players[0].strategic_ledger.get("iron_ore").copied(),
Some(3),
"consumed 2 ore"
);
assert_eq!(state.players[0].units[0].equipped.len(), 1);
assert_eq!(state.players[0].units[0].equipped[0].item_id, "bronze_sword");
}
#[test]
fn craft_equipment_rejects_insufficient_materials() {
let mut state = make_state_with_units(vec![(0, 1, 5, 5)]);
state.players[0].strategic_ledger = [("iron_ore".to_string(), 1u32)].into_iter().collect();
state.item_combat.insert("bronze_sword".to_string(), sword_def());
assert!(
craft_equipment(&mut state, "1", "bronze_sword").is_err(),
"insufficient materials → reject"
);
assert!(
state.players[0].units[0].equipped.is_empty(),
"nothing equipped on rejected craft"
);
assert_eq!(
state.players[0].strategic_ledger.get("iron_ore").copied(),
Some(1),
"materials untouched on reject"
);
}
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"
);
}
/// p2-67 Phase 11. `apply_end_turn` now runs `TurnProcessor::step`
/// between the AI driver loop and the closing `TurnStarted` emit.
/// Step accumulates food into `CityState.food_stored`; once the
/// threshold is crossed, `population` grows. This test seeds a city
/// with food_yield = 12 (well above the pop-1 maintenance of 2) so
/// the food_stored crosses the threshold in 2 turns and population
/// rises from 1 → 2.
#[test]
fn end_turn_ticks_city_food_growth_via_turn_processor() {
use mc_city::CityState;
let mut state = GameState::default();
let mut ps = PlayerState::default();
ps.player_index = 0;
let mut city = CityState::starter();
// Starter has population=1, food_stored=0, food_yield=4 (net +2 / turn).
// Threshold at pop=1 is `15 + 6*0 + 0 = 15`. With a juiced food_yield
// of 16 (net +14 / turn) we cross in 2 turns deterministically.
city.food_yield = 16;
ps.cities.push(city);
ps.city_positions.push((0, 0));
ps.city_buildings.push(Vec::new());
ps.city_improvements.push(Vec::new());
ps.city_ecology.push(Default::default());
state.players.push(ps);
// Turn 0 → state.turn becomes 1, food_stored = 14, population still 1.
let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap();
assert_eq!(state.turn, 1);
assert_eq!(state.players[0].cities[0].population, 1);
assert_eq!(state.players[0].cities[0].food_stored, 14);
// Turn 1 → food_stored crosses 15, pop grows to 2.
let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap();
assert_eq!(state.turn, 2);
assert_eq!(
state.players[0].cities[0].population, 2,
"city should have grown to population 2"
);
// food_stored after growth = 14 (pre-grow) + 14 (this turn's net) - 15 (threshold) = 13.
// (The threshold check happens AFTER this turn's food is added but
// BEFORE the maintenance is rebilled for the new pop — `process_city_production`
// accumulates pre-grow.)
assert_eq!(state.players[0].cities[0].food_stored, 13);
}
/// p2-67 Phase 11. With a queue carrying a `Queueable::Unit` and
/// enough `production_stored`, `try_spawn_unit` in `TurnProcessor::step`
/// spawns a real unit at the city's hex. Asserts via state inspection
/// (no `CityUnitCompleted` wire event exists today — the production
/// path mutates `player.units` silently).
#[test]
fn end_turn_completes_queued_unit_via_turn_processor() {
use mc_city::{CityState, Queueable};
let mut state = GameState::default();
let mut ps = PlayerState::default();
ps.player_index = 0;
let mut city = CityState::starter();
// Pre-stuff production_stored above the spawn cost (4 by default
// — see `LairCombatConfig::default`'s `unit_spawn_cost`). Anything
// ≥ 4 lets `try_spawn_unit` trigger.
city.production_stored = 100;
city.queue = Some(Queueable::Unit {
unit_id: "dwarf_warrior".into(),
});
city.queue_cost = Some(8);
ps.cities.push(city);
ps.city_positions.push((5, 5));
ps.city_buildings.push(Vec::new());
ps.city_improvements.push(Vec::new());
ps.city_ecology.push(Default::default());
state.players.push(ps);
let unit_count_before = state.players[0].units.len();
let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap();
assert_eq!(state.turn, 1);
assert!(
state.players[0].units.len() > unit_count_before,
"queued unit should have spawned (units before={}, after={})",
unit_count_before,
state.players[0].units.len()
);
}
/// p2-67 Phase 11. Unit movement_remaining refresh is now owned by
/// `TurnProcessor::step` (DRY rule — the dispatch-level `refresh_units`
/// call was deleted in this same patch). Asserts a fortified unit that
/// exhausted its movement budget gets a fresh budget after EndTurn.
#[test]
fn end_turn_refreshes_unit_movement_via_turn_processor() {
let mut state = make_state_with_units(vec![(0, 1, 3, 3)]);
// make_state_with_units gives every unit `with_moves(32)` —
// simulate a consumed budget.
state.players[0].units[0].movement_remaining = 0;
assert_eq!(state.players[0].units[0].base_moves, 32);
let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap();
assert_eq!(state.turn, 1);
assert_eq!(
state.players[0].units[0].movement_remaining, 32,
"movement should refresh to base_moves after step"
);
}
#[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_dispatches_envelope_without_signing() {
// Communications Phase 4: Open Borders offers travel by
// envelope. At dispatch time the trade ledger is unchanged —
// the agreement push is deferred to envelope delivery in
// `comms_dispatch::apply_treaty_offer_delivery`.
let mut state = GameState::default();
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(), "Phase 4 dispatch emits no synchronous events");
assert!(
state.trade_ledger.agreements.is_empty(),
"agreement is NOT signed at dispatch — it lands on envelope delivery"
);
assert_eq!(
state.comms.envelopes.len(),
1,
"an envelope rides the comms channel"
);
let env = state.comms.envelopes.values().next().unwrap();
assert_eq!(env.payload.kind(), "treaty_offer");
}
#[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_marks_pending_overlay_not_shared_relation() {
// Communications Phase 2: war declaration travels by envelope.
// Sender's `pending_war_dec` overlay flips at dispatch; the
// shared `RelationState` only flips on envelope delivery (or
// on first combat, whichever comes first). This test asserts
// the dispatch-time semantics: sender overlay set, shared cell
// still at default (Neutral / no entry).
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();
assert!(
state.comms.has_outbound_war(0, 1),
"sender's pending war-dec overlay must be set on dispatch"
);
let rel = state.players[0]
.relations
.get(&(0, 1))
.map(|rs| rs.relation)
.unwrap_or(mc_trade::relation::Relation::Neutral);
assert_ne!(
rel,
mc_trade::relation::Relation::War,
"shared RelationState must NOT flip until envelope delivery"
);
}
#[test]
fn accept_peace_dispatches_envelope_without_flipping_relation() {
// Communications Phase 4: AcceptPeace now dispatches a
// `TreatyAccept { agreement_kind: "peace" }` envelope. The
// shared `Relation` cell stays War until envelope delivery.
let mut state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 4, 4)]);
state.players[0]
.relations
.entry((0, 1))
.or_default()
.relation = mc_trade::relation::Relation::War;
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::War,
"shared cell stays War until envelope delivers"
);
assert_eq!(state.comms.envelopes.len(), 1);
let env = state.comms.envelopes.values().next().unwrap();
assert_eq!(env.payload.kind(), "treaty_accept");
}
#[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 suggest_actions_is_deterministic_and_non_mutating() {
// Stage 6.1.6 — `suggest_actions` must be a pure read: two calls
// on the same state produce an identical chain, and the state is
// byte-for-byte unchanged afterwards.
let state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 5, 5)]);
// Snapshot via JSON dump (GameState has no PartialEq).
let before = serde_json::to_string(&state).unwrap();
let first = suggest_actions(&state, 1);
let after_first = serde_json::to_string(&state).unwrap();
assert_eq!(
before, after_first,
"suggest_actions must not mutate GameState"
);
let second = suggest_actions(&state, 1);
let after_second = serde_json::to_string(&state).unwrap();
assert_eq!(
before, after_second,
"a second suggest_actions call must not mutate GameState"
);
// Determinism: same state + slot → same chain. Compare via the
// PlayerAction wire JSON (PlayerAction has no PartialEq for the
// whole Vec in one shot, but the per-action JSON is canonical).
let first_json = serde_json::to_string(&first).unwrap();
let second_json = serde_json::to_string(&second).unwrap();
assert_eq!(
first_json, second_json,
"suggest_actions must be deterministic for a fixed (state, slot)"
);
}
#[test]
fn suggest_actions_out_of_range_slot_is_empty() {
let state = make_state_with_units(vec![(0, 1, 0, 0)]);
// Only slot 0 exists; slot 9 is out of range.
assert!(suggest_actions(&state, 9).is_empty());
}
#[test]
fn ai_set_production_emits_projector_wire_city_id() {
// Regression (hotseat playthrough, 2026-06-23): the AI→PlayerAction
// converter used by `suggest_actions` (and its `apply_ai_action`
// twin) emitted the bare tactical city index ("0"), but the
// projector wire id — and therefore `find_city_indices` — is
// "{player}_{c_idx}". Every AI/suggested `queue_production` failed
// `UnknownCity`. Both `SetProduction` and `EnqueueBuild` must emit
// the wire form for the bound slot.
let state = make_state_with_units(vec![(1, 1, 0, 0)]); // players 0 and 1
for action in [
mc_ai::tactical::Action::SetProduction { city_id: 0, item_id: "walls".into() },
mc_ai::tactical::Action::EnqueueBuild {
city_id: 0,
item_id: "walls".into(),
building_origin: "__city_center__".into(),
},
] {
let pa = ai_action_to_player_action(&state, 1, &action)
.expect("production action must be representable on the wire");
match pa {
PlayerAction::QueueProduction { city_id, .. } => {
assert_eq!(city_id, "1_0", "must emit projector wire id, not bare index");
}
other => panic!("expected QueueProduction, got {other:?}"),
}
}
}
#[test]
fn ai_declare_war_maps_to_player_declare_war() {
// p3-16: the AI's war-dec must round-trip through the suggest/wire
// converter to PlayerAction::DeclareWar so it routes to the same
// courier dispatch the human uses.
let state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 5, 5)]);
let action = mc_ai::tactical::Action::DeclareWar { target: 1 };
match ai_action_to_player_action(&state, 0, &action) {
Some(PlayerAction::DeclareWar { on }) => assert_eq!(on, 1),
other => panic!("expected DeclareWar{{on:1}}, got {other:?}"),
}
}
#[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();
}
}