feat(mc-ai): ⚔️ AI proactive war-declaration via the courier system (p3-16)

The AI had no war-declaration logic — decide_tactical_actions ran
movement→combat→settle→production→citizens with no diplomacy step, and there
was no DeclareWar anywhere in mc-ai. Under the courier model (pairs start at
peace, war begins on war-dec dispatch) that meant AI-vs-AI sat at perpetual
peace: no enemy targets, armies never maneuvered, and clan aggression never
manifested (warmonger == builder).

- New decide_diplomacy step (runs first): opens hostilities against a
  *discovered* rival (visible units/cities in the fog projection) once own
  military clears an aggression-scaled superiority bar (thresholds::
  dominance_factor — warmongers strike near parity, cautious clans need an
  edge). Pure/deterministic.
- New Action::DeclareWar { target }; routed in both dispatch converters to
  PlayerAction::DeclareWar → apply_declare_war → comms_dispatch::
  dispatch_war_declaration (same path the human uses; sender enters War on
  dispatch). Rollout apply flips the relation for lookahead fidelity.
- Made movement::{is_at_war,count_military} pub(super); refreshed the stale
  is_at_war comment to the courier model (per p3-16 cleanup-alongside note).
- Tests: 5 mc-ai diplomacy cases (discovery gate, already-at-war, no-army,
  aggression bar) + a dispatch round-trip. mc-ai 274 + mc-player-api 131 green.

Proven live (hotseat self-play, seed 42): war-decs dispatch on first contact
(turns 17/18). Full aggressive play is still capped by a SEPARATE gap — the AI
does not scout, so it rarely sees enemy *cities* to march on even once at war.
That exploration gap is the next limiter, tracked separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-23 20:22:40 -04:00
parent 6b3b571806
commit 9d2d2bee8b
5 changed files with 263 additions and 10 deletions

View file

@ -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;
}
}
}
}
}

View file

@ -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<Instant>,
) -> Vec<Action> {
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<TacticalUnit>, cities: Vec<TacticalCity>) -> 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<TacticalPlayerState>, 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<Action> {
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());
}
}

View file

@ -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;

View file

@ -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()
}

View file

@ -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