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:
parent
6b3b571806
commit
9d2d2bee8b
5 changed files with 263 additions and 10 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
191
src/simulator/crates/mc-ai/src/tactical/diplomacy.rs
Normal file
191
src/simulator/crates/mc-ai/src/tactical/diplomacy.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue