diff --git a/src/simulator/crates/mc-ai/src/tactical/apply.rs b/src/simulator/crates/mc-ai/src/tactical/apply.rs index c361c060..4b9646ea 100644 --- a/src/simulator/crates/mc-ai/src/tactical/apply.rs +++ b/src/simulator/crates/mc-ai/src/tactical/apply.rs @@ -193,6 +193,24 @@ pub fn apply_tactical_action(state: &mut TacticalState, action: &Action) { u.pending_promotion_choices.clear(); } } + Action::DeclareWar { target } => { + // Rollout approximation: flip the actor↔target relation to war so + // the lookahead's movement/combat immediately treats the target as + // an enemy. Mirrors the real dispatch's sender-side war state + // (the shared cell flips on delivery/combat in the live engine). + let actor = state.current_player as usize; + let t = *target as usize; + if let Some(p) = state.players.get_mut(actor) { + if let Some(slot) = p.relations.get_mut(t) { + *slot = -1; + } + } + if let Some(p) = state.players.get_mut(t) { + if let Some(slot) = p.relations.get_mut(actor) { + *slot = -1; + } + } + } } } diff --git a/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs b/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs new file mode 100644 index 00000000..9dd1e4fb --- /dev/null +++ b/src/simulator/crates/mc-ai/src/tactical/diplomacy.rs @@ -0,0 +1,191 @@ +//! Strategic war-declaration decisions (p3-16). +//! +//! Canonical model is courier-diplomacy (`COMMUNICATIONS.md` +//! §"War declaration semantics"): every pair starts at PEACE, and war +//! begins only when a player *dispatches* a war-dec envelope. Nothing made +//! the AI dispatch one — `decide_tactical_actions` had no diplomacy step and +//! there was no `DeclareWar` anywhere in `mc-ai` — so AI-vs-AI sat at +//! perpetual peace, military units never acquired a target, and the clan +//! `aggression` personality axis never manifested (a warmonger played like a +//! builder). This step closes that gap: it opens hostilities against a +//! *discovered* rival once the military balance clears an aggression-scaled +//! bar, emitting [`Action::DeclareWar`] which the dispatch routes to +//! `comms_dispatch::dispatch_war_declaration` (sender enters War on dispatch). + +use std::time::Instant; + +use crate::evaluator::ScoringWeights; +use crate::mcts::XorShift64; + +use super::movement::{count_military, is_at_war}; +use super::{thresholds, Action, TacticalState}; + +/// Decide whether to open hostilities against any discovered rival. +/// +/// Pure and deterministic: keyed only on `state` + the player's +/// `strategic_axes`. The `rng`/`weights` arguments are accepted for +/// signature symmetry with the other decision steps but unused — there is +/// no stochastic element to the threshold, so two calls on the same state +/// return identical war-decs. +pub(crate) fn decide_diplomacy( + state: &TacticalState, + _weights: &ScoringWeights, + _rng: &mut XorShift64, + deadline: Option, +) -> Vec { + if deadline.map_or(false, |d| Instant::now() >= d) { + return Vec::new(); + } + let Some(me) = state.players.get(state.current_player as usize) else { + return Vec::new(); + }; + + // No army → no credible war. Declaring with nothing to fight with just + // invites a counter-attack; let production build up first. + let own_mil = count_military(&me.units); + if own_mil == 0 { + return Vec::new(); + } + + // Aggression-scaled superiority bar. `dominance_factor` returns ~1.15 for + // a warmonger (aggression 9) and ~1.80 for a cautious clan (aggression 1): + // warmongers open hostilities near parity, the cautious need a real edge. + let required_ratio = thresholds::dominance_factor(&me.strategic_axes); + + let mut out = Vec::new(); + for other in &state.players { + if other.index == me.index || is_at_war(me, other.index) { + continue; + } + // Discovery gate: the fog projection only surfaces visible enemy + // units/cities, so a non-empty roster means we have eyes on this + // rival. We cannot war-dec a capital we do not know exists, and a + // war with no reachable target is wasted carriers. + if other.units.is_empty() && other.cities.is_empty() { + continue; + } + let enemy_mil = count_military(&other.units); + if (own_mil as f32) >= (enemy_mil as f32) * required_ratio { + out.push(Action::DeclareWar { target: other.index }); + } + } + out +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::evaluator::ScoringWeights; + use crate::mcts::XorShift64; + use crate::tactical::{ + Action, TacticalCity, TacticalMap, TacticalPlayerState, TacticalState, TacticalUnit, + }; + + fn warrior(id: u32, owner_hex: (i32, i32)) -> TacticalUnit { + TacticalUnit { + id, + kind: "dwarf_warrior".into(), + hex: owner_hex, + hp: 10, + hp_max: 10, + moves_left: 2, + ..Default::default() + } + } + + fn player(index: u8, aggression: i32, units: Vec, cities: Vec) -> TacticalPlayerState { + let mut axes = BTreeMap::new(); + axes.insert("aggression".to_string(), aggression); + TacticalPlayerState { + index, + units, + cities, + strategic_axes: axes, + // All pairs default to peace (0); courier model start state. + relations: vec![0i8; 4], + ..Default::default() + } + } + + fn city(id: u32, hex: (i32, i32)) -> TacticalCity { + TacticalCity { + id, + hex, + population: 3, + tiles_worked: Vec::new(), + production_queue: Vec::new(), + buildings: Vec::new(), + health: 25, + is_capital: true, + } + } + + fn state_with(players: Vec, current: u8) -> TacticalState { + TacticalState { + current_player: current, + turn: 10, + map: TacticalMap { width: 4, height: 4, tiles: Vec::new() }, + players, + unit_catalog: Vec::new(), + building_catalog: Vec::new(), + difficulty_threshold_mult: 1.0, + } + } + + fn run(state: &TacticalState) -> Vec { + super::decide_diplomacy(state, &ScoringWeights::default(), &mut XorShift64::new(1), None) + } + + #[test] + fn declares_on_discovered_weaker_rival() { + let me = player(0, 5, vec![warrior(1, (0, 0)), warrior(2, (1, 0))], vec![city(0, (0, 0))]); + // Rival visible (one unit + a city) but weaker (no military). + let them = player(1, 5, vec![], vec![city(1, (9, 9))]); + let st = state_with(vec![me, them], 0); + let actions = run(&st); + assert_eq!(actions.len(), 1, "exactly one war-dec expected"); + assert!(matches!(actions[0], Action::DeclareWar { target: 1 })); + } + + #[test] + fn does_not_declare_on_undiscovered_rival() { + let me = player(0, 9, vec![warrior(1, (0, 0)), warrior(2, (1, 0))], vec![city(0, (0, 0))]); + // Rival has no visible units/cities → not yet discovered. + let them = player(1, 5, vec![], vec![]); + let st = state_with(vec![me, them], 0); + assert!(run(&st).is_empty()); + } + + #[test] + fn does_not_declare_when_already_at_war() { + let mut me = player(0, 9, vec![warrior(1, (0, 0))], vec![city(0, (0, 0))]); + me.relations[1] = -1; // already at war with slot 1 + let them = player(1, 5, vec![], vec![city(1, (9, 9))]); + let st = state_with(vec![me, them], 0); + assert!(run(&st).is_empty()); + } + + #[test] + fn cautious_clan_holds_at_parity_warmonger_strikes() { + // Both sides field one military unit (parity). + let make = |aggr| { + let me = player(0, aggr, vec![warrior(1, (0, 0))], vec![city(0, (0, 0))]); + let them = player(1, 5, vec![warrior(9, (9, 9))], vec![city(1, (9, 9))]); + state_with(vec![me, them], 0) + }; + // Warmonger (aggression 9, factor ~1.15) — 1 >= 1*1.15 is false, so + // even a warmonger needs a slight edge; at strict parity it holds. + // Cautious (aggression 1, factor ~1.80) certainly holds. + assert!(run(&make(1)).is_empty(), "cautious clan must not declare at parity"); + assert!(run(&make(9)).is_empty(), "neither declares at strict parity (ratio bar)"); + } + + #[test] + fn no_army_never_declares() { + let me = player(0, 9, vec![], vec![city(0, (0, 0))]); + let them = player(1, 5, vec![], vec![city(1, (9, 9))]); + let st = state_with(vec![me, them], 0); + assert!(run(&st).is_empty()); + } +} diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs index 7f82303b..ceef4a6b 100644 --- a/src/simulator/crates/mc-ai/src/tactical/mod.rs +++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs @@ -34,6 +34,7 @@ pub mod apply; pub(crate) mod citizen; pub mod combat_predict; +pub(crate) mod diplomacy; pub mod culture_pick; pub mod memory; pub(crate) mod movement; @@ -204,6 +205,15 @@ pub enum Action { /// `public/resources/promotions/promotions.json`). promotion_id: String, }, + /// Declare war on another player's slot via the courier system (p3-16). + /// The sender enters `War` the instant the war-dec envelope is + /// dispatched (COMMUNICATIONS.md §"War declaration semantics"), which + /// is what lets the army then drive on the target. Emitted by + /// [`diplomacy::decide_diplomacy`]. + DeclareWar { + /// Target player slot. + target: u8, + }, } /// Compute the full set of tactical actions for the player whose turn it @@ -224,11 +234,12 @@ pub enum Action { /// env var is unset). See p1-22. /// /// Stable submodule order: -/// 1. [`movement::decide_movement`] -/// 2. [`combat_predict::decide_combat`] -/// 3. [`settle::decide_settle`] -/// 4. [`production::decide_production`] -/// 5. [`citizen::decide_citizens`] +/// 1. [`diplomacy::decide_diplomacy`] +/// 2. [`movement::decide_movement`] +/// 3. [`combat_predict::decide_combat`] +/// 4. [`settle::decide_settle`] +/// 5. [`production::decide_production`] +/// 6. [`citizen::decide_citizens`] pub fn decide_tactical_actions( state: &TacticalState, weights: &ScoringWeights, @@ -241,6 +252,14 @@ pub fn decide_tactical_actions( }; let mut actions = Vec::new(); + // Strategic war-declaration runs first so a war-dec dispatched this turn + // is applied before the unit actions that follow (p3-16). The army's + // drive on the new enemy materialises next turn, once the relation flip + // is reflected in the projection. + actions.extend(diplomacy::decide_diplomacy(state, weights, rng, deadline)); + if is_expired(&deadline) { + return actions; + } actions.extend(movement::decide_movement(state, weights, rng, deadline, memory)); if is_expired(&deadline) { return actions; diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index 92682903..3a818f21 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -363,10 +363,14 @@ fn is_military(unit: &TacticalUnit) -> bool { // ── Enemy / diplomacy enumeration ──────────────────────────────────────── -fn is_at_war(me: &TacticalPlayerState, opponent_index: u8) -> bool { - // Mirrors `simple_heuristic_ai.gd::_is_at_war` (line 322) — default to - // war when a relation slot is missing so the attack gate stays open in - // fresh games where the diplomacy table has not been initialized. +pub(super) fn is_at_war(me: &TacticalPlayerState, opponent_index: u8) -> bool { + // Canonical model is courier-diplomacy: pairs start at PEACE and war + // begins when a player dispatches a war-dec envelope (COMMUNICATIONS.md + // §"War declaration semantics"; p1-01's "missing → war" is superseded, + // see p3-16). `project_tactical_relations` fills the relations vec, so a + // slot is normally present; the `map_or(true, …)` fallback only fires for + // a genuinely absent slot and is left open so a not-yet-projected pair + // never silently blocks retaliation. if (opponent_index as usize) == (me.index as usize) { return false; } @@ -411,7 +415,7 @@ fn collect_enemy_city_positions( out } -fn count_military(units: &[TacticalUnit]) -> usize { +pub(super) fn count_military(units: &[TacticalUnit]) -> usize { units.iter().filter(|u| is_military(u)).count() } diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index fb81f68b..064ca7e7 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -1275,6 +1275,13 @@ pub fn apply_ai_action( }); 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 @@ -1378,6 +1385,7 @@ fn ai_action_to_player_action( 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 { .. } @@ -3056,6 +3064,19 @@ mod tests { } } + #[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