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>
3313 lines
133 KiB
Rust
3313 lines
133 KiB
Rust
//! Action dispatch — drives a `PlayerAction` against a real `GameState`.
|
||
//!
|
||
//! Maps each `PlayerAction` variant onto its underlying simulator entry
|
||
//! point:
|
||
//!
|
||
//! - **Unit verbs** that don't need target coords
|
||
//! (`Fortify`, `Unfortify`, `Skip`, `FoundCity`, `BuildImprovement`,
|
||
//! `IssuePatrol`, `CancelPatrol`, `EditPatrol`, `PillageFriendly`,
|
||
//! `Embark`, `Disembark`, `Sentry`, `Unsentry`,
|
||
//! `AimedShot` / `FireArrows` / `StopFireArrows`) →
|
||
//! `mc_turn::action_handlers::invoke(state, player_idx, unit_idx, kind)`.
|
||
//! - **Targeted unit verbs** (`Move`, `Attack`, `RangedAttack`) →
|
||
//! enqueued onto the matching `pending_*` request vector on
|
||
//! `GameState`. The turn processor drains those queues during
|
||
//! end-of-turn resolution.
|
||
//! - **`EndTurn`** → increments `GameState.turn` and emits a
|
||
//! `TurnEnded` / `TurnStarted` event pair.
|
||
//! - **`Noop`** → no state change, no events.
|
||
//!
|
||
//! Variants that require subsystems still being authored
|
||
//! (`QueueProduction`, `RushBuy`, `BuyTile`, `SetFocus`,
|
||
//! `MergeBuildings`, `BuildingAction`, `ResearchTech`,
|
||
//! `ResearchTradition`, `SwitchCivic`, every `Diplomacy` variant,
|
||
//! formation commands, `Promote`) return
|
||
//! `ActionError::NotYetImplemented` with a tracked breadcrumb naming
|
||
//! the follow-up task. The wire surface is final; only the dispatcher
|
||
//! body grows.
|
||
//!
|
||
//! All event payloads carry the canonical `Event` variants from
|
||
//! [`crate::wire`], so adapter consumers can decode against one stable
|
||
//! enum regardless of which dispatch path produced them.
|
||
|
||
use mc_core::action::ActionKind;
|
||
use mc_core::civic::{AxisChoice, CivicAxis as CoreCivicAxis};
|
||
use mc_turn::action_handlers;
|
||
use mc_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();
|
||
}
|
||
}
|