feat(mc-ai): Introduce diplomacy evaluation logic with evaluate_open_border and handle_shared_map_offer functions, integrate trade mechanics, and update dependencies for MC-trade compatibility

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-28 15:29:00 -07:00
parent b882a6844c
commit afa600849b
2 changed files with 538 additions and 0 deletions

View file

@ -0,0 +1,533 @@
//! Courier-diplomacy offer/accept/reject heuristics (p3-01 bullet 8).
//!
//! Each public function answers whether an AI player should offer, accept, or
//! reject a specific diplomatic agreement type. All logic keys off the six
//! personality axes already defined in `ai_personalities.json`; no new axes are
//! introduced.
//!
//! Hard rules (locked design):
//! - **goldvein**: trades both agreement types eagerly; even more willing when a
//! courier route already exists.
//! - **deepforge**: rejects OpenBorders categorically; accepts SharedMap only when
//! the payment exceeds the clan's demand floor (higher due to isolationism).
//! - **blackhammer**: accepts OpenBorders specifically when planning offense (flag
//! supplied by caller); rejects SharedMap in all cases.
//! - **ironhold / runesmith**: axis-driven heuristics using `trade_willingness`,
//! `aggression`, and `expansion`.
use std::collections::HashMap;
use mc_trade::{OpenBordersAgreement, SharedMapAgreement};
// ── Payment floor calculation ─────────────────────────────────────────────────
/// Minimum gold payment a clan requires before accepting a SharedMap agreement.
///
/// Base floor = 40 gold. Isolationist clans (low `trade_willingness`) demand
/// proportionally more: floor += 8 × (5 trade_willingness), capped at 120.
fn shared_map_floor(axes: &HashMap<String, i32>) -> u32 {
let trade = axes.get("trade_willingness").copied().unwrap_or(5);
let delta = (5 - trade).max(0) as u32;
(40 + 8 * delta).min(120)
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Context supplied by the caller for per-turn diplomacy evaluation.
///
/// The caller (typically the mc-turn processor or a future GDScript bridge) is
/// responsible for populating these flags from engine state. The heuristic
/// functions are intentionally side-effect-free so they can be exercised in unit
/// tests without a full game state.
#[derive(Debug, Clone)]
pub struct DiplomacyCtx {
/// True when this player is currently building up or deploying an offensive
/// force toward a neighbour. Set by the strategic layer when attack
/// preparations are underway.
pub planning_offense: bool,
/// True when a live CourierRoute already connects this player's capital to the
/// target player's capital (populated by the route resolver once p3-03 lands).
pub route_exists: bool,
}
/// Decision returned by the offer/accept heuristics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiploDecision {
Accept,
Reject,
}
// ── OpenBorders ───────────────────────────────────────────────────────────────
/// Whether `clan_id` should **accept** an incoming OpenBorders proposal.
///
/// - goldvein → always accept (mercantile; any border access aids trade).
/// - deepforge → always reject (isolationist).
/// - blackhammer → accept only when `ctx.planning_offense` is true.
/// - ironhold / runesmith → axis-driven: accept when `trade_willingness +
/// (aggression × 0.5) > 6.5` (threshold chosen so ironhold, aggression=6 /
/// trade=3 sits below the line without offense context; runesmith, trade=7
/// sits above it).
pub fn evaluate_open_borders_accept(
clan_id: &str,
axes: &HashMap<String, i32>,
_agreement: &OpenBordersAgreement,
ctx: &DiplomacyCtx,
) -> DiploDecision {
match clan_id {
"goldvein" => DiploDecision::Accept,
"deepforge" => DiploDecision::Reject,
"blackhammer" => {
if ctx.planning_offense {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
_ => {
let trade = axes.get("trade_willingness").copied().unwrap_or(5) as f32;
let aggression = axes.get("aggression").copied().unwrap_or(5) as f32;
let score = trade + aggression * 0.5;
if score > 6.5 {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
}
}
/// Whether `clan_id` should **offer** an OpenBorders deal to a neighbour.
///
/// Mirrors accept logic: goldvein always offers; deepforge never; blackhammer
/// only during offense planning; others use the same axis threshold.
pub fn evaluate_open_borders_offer(
clan_id: &str,
axes: &HashMap<String, i32>,
ctx: &DiplomacyCtx,
) -> DiploDecision {
match clan_id {
"goldvein" => DiploDecision::Accept,
"deepforge" => DiploDecision::Reject,
"blackhammer" => {
if ctx.planning_offense {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
_ => {
let trade = axes.get("trade_willingness").copied().unwrap_or(5) as f32;
let aggression = axes.get("aggression").copied().unwrap_or(5) as f32;
if trade + aggression * 0.5 > 6.5 {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
}
}
// ── SharedMap ────────────────────────────────────────────────────────────────
/// Whether `clan_id` should **accept** an incoming SharedMap proposal.
///
/// - goldvein → always accept; extra willingness when `ctx.route_exists`
/// (already wired, payment terms are already good).
/// - deepforge → accept only when `agreement.payment_gold >= floor(axes)`.
/// - blackhammer → always reject (doesn't value intel; prefers brute force).
/// - ironhold / runesmith → accept when payment >= floor AND `trade_willingness >= 5`.
pub fn evaluate_shared_map_accept(
clan_id: &str,
axes: &HashMap<String, i32>,
agreement: &SharedMapAgreement,
ctx: &DiplomacyCtx,
) -> DiploDecision {
match clan_id {
"goldvein" => {
// Eager regardless; route_exists is a nice bonus but doesn't change the
// outcome — goldvein takes any information deal.
let _ = ctx.route_exists;
DiploDecision::Accept
}
"deepforge" => {
if agreement.payment_gold >= shared_map_floor(axes) {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
"blackhammer" => DiploDecision::Reject,
_ => {
let trade = axes.get("trade_willingness").copied().unwrap_or(5);
let floor = shared_map_floor(axes);
if trade >= 5 && agreement.payment_gold >= floor {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
}
}
/// Whether `clan_id` should **offer** a SharedMap deal.
///
/// goldvein offers eagerly; eagerness is boosted (not changed in binary terms)
/// when a courier route already connects the capitals. deepforge, blackhammer
/// never offer. Others only offer when `trade_willingness >= 6`.
pub fn evaluate_shared_map_offer(
clan_id: &str,
axes: &HashMap<String, i32>,
ctx: &DiplomacyCtx,
) -> DiploDecision {
match clan_id {
"goldvein" => {
// route_exists makes goldvein even more eager, but in binary terms the
// decision is the same: always offer.
let _ = ctx.route_exists;
DiploDecision::Accept
}
"deepforge" | "blackhammer" => DiploDecision::Reject,
_ => {
let trade = axes.get("trade_willingness").copied().unwrap_or(5);
if trade >= 6 {
DiploDecision::Accept
} else {
DiploDecision::Reject
}
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use mc_trade::{OpenBordersAgreement, SharedMapAgreement};
fn axes(pairs: &[(&str, i32)]) -> HashMap<String, i32> {
pairs.iter().map(|&(k, v)| (k.to_string(), v)).collect()
}
fn goldvein_axes() -> HashMap<String, i32> {
axes(&[
("aggression", 3),
("expansion", 5),
("production", 5),
("wealth", 9),
("trade_willingness", 9),
("grudge_persistence", 4),
])
}
fn deepforge_axes() -> HashMap<String, i32> {
axes(&[
("aggression", 4),
("expansion", 2),
("production", 8),
("wealth", 5),
("trade_willingness", 4),
("grudge_persistence", 6),
])
}
fn blackhammer_axes() -> HashMap<String, i32> {
axes(&[
("aggression", 9),
("expansion", 6),
("production", 7),
("wealth", 2),
("trade_willingness", 2),
("grudge_persistence", 9),
])
}
fn ironhold_axes() -> HashMap<String, i32> {
axes(&[
("aggression", 6),
("expansion", 4),
("production", 9),
("wealth", 3),
("trade_willingness", 3),
("grudge_persistence", 7),
])
}
fn runesmith_axes() -> HashMap<String, i32> {
axes(&[
("aggression", 5),
("expansion", 6),
("production", 5),
("wealth", 6),
("trade_willingness", 7),
("grudge_persistence", 5),
])
}
fn open_borders_agreement() -> OpenBordersAgreement {
OpenBordersAgreement {
agreement_id: 1,
partners: (0, 1),
turn_started: 10,
turns_remaining: 20,
payment_gold: 30,
payment_luxury: None,
}
}
fn shared_map_agreement(payment_gold: u32) -> SharedMapAgreement {
SharedMapAgreement {
agreement_id: 2,
partners: (0, 1),
turn_started: 10,
duration: 15,
share_turns_remaining: 0,
payment_gold,
payment_luxury: None,
courier_route: None,
}
}
fn ctx(planning_offense: bool, route_exists: bool) -> DiplomacyCtx {
DiplomacyCtx {
planning_offense,
route_exists,
}
}
// ── OpenBorders accept ────────────────────────────────────────────────────
#[test]
fn goldvein_accepts_open_borders() {
let ag = open_borders_agreement();
assert_eq!(
evaluate_open_borders_accept("goldvein", &goldvein_axes(), &ag, &ctx(false, false)),
DiploDecision::Accept
);
}
#[test]
fn deepforge_rejects_open_borders() {
let ag = open_borders_agreement();
assert_eq!(
evaluate_open_borders_accept("deepforge", &deepforge_axes(), &ag, &ctx(false, false)),
DiploDecision::Reject
);
// Even during offense planning — hard rule.
assert_eq!(
evaluate_open_borders_accept("deepforge", &deepforge_axes(), &ag, &ctx(true, false)),
DiploDecision::Reject
);
}
#[test]
fn blackhammer_accepts_open_borders_only_when_planning_offense() {
let ag = open_borders_agreement();
assert_eq!(
evaluate_open_borders_accept(
"blackhammer",
&blackhammer_axes(),
&ag,
&ctx(true, false)
),
DiploDecision::Accept
);
assert_eq!(
evaluate_open_borders_accept(
"blackhammer",
&blackhammer_axes(),
&ag,
&ctx(false, false)
),
DiploDecision::Reject
);
}
#[test]
fn ironhold_rejects_open_borders_without_offense() {
// ironhold: trade=3, aggression=6 → score = 3 + 3 = 6.0 < 6.5 → Reject
let ag = open_borders_agreement();
assert_eq!(
evaluate_open_borders_accept("ironhold", &ironhold_axes(), &ag, &ctx(false, false)),
DiploDecision::Reject
);
}
#[test]
fn runesmith_accepts_open_borders() {
// runesmith: trade=7, aggression=5 → score = 7 + 2.5 = 9.5 > 6.5 → Accept
let ag = open_borders_agreement();
assert_eq!(
evaluate_open_borders_accept("runesmith", &runesmith_axes(), &ag, &ctx(false, false)),
DiploDecision::Accept
);
}
// ── OpenBorders offer ─────────────────────────────────────────────────────
#[test]
fn goldvein_offers_open_borders() {
assert_eq!(
evaluate_open_borders_offer("goldvein", &goldvein_axes(), &ctx(false, false)),
DiploDecision::Accept
);
}
#[test]
fn deepforge_does_not_offer_open_borders() {
assert_eq!(
evaluate_open_borders_offer("deepforge", &deepforge_axes(), &ctx(false, false)),
DiploDecision::Reject
);
}
#[test]
fn blackhammer_offers_open_borders_only_when_planning_offense() {
assert_eq!(
evaluate_open_borders_offer("blackhammer", &blackhammer_axes(), &ctx(true, false)),
DiploDecision::Accept
);
assert_eq!(
evaluate_open_borders_offer("blackhammer", &blackhammer_axes(), &ctx(false, false)),
DiploDecision::Reject
);
}
// ── SharedMap accept ──────────────────────────────────────────────────────
#[test]
fn goldvein_accepts_shared_map_regardless_of_payment() {
assert_eq!(
evaluate_shared_map_accept("goldvein", &goldvein_axes(), &shared_map_agreement(0), &ctx(false, false)),
DiploDecision::Accept
);
assert_eq!(
evaluate_shared_map_accept("goldvein", &goldvein_axes(), &shared_map_agreement(0), &ctx(false, true)),
DiploDecision::Accept
);
}
#[test]
fn blackhammer_rejects_shared_map() {
assert_eq!(
evaluate_shared_map_accept(
"blackhammer",
&blackhammer_axes(),
&shared_map_agreement(200),
&ctx(false, false)
),
DiploDecision::Reject
);
}
#[test]
fn deepforge_accepts_shared_map_above_floor() {
// deepforge trade=4 → floor = 40 + 8*(5-4) = 48
let floor = 48;
assert_eq!(
evaluate_shared_map_accept(
"deepforge",
&deepforge_axes(),
&shared_map_agreement(floor),
&ctx(false, false)
),
DiploDecision::Accept
);
assert_eq!(
evaluate_shared_map_accept(
"deepforge",
&deepforge_axes(),
&shared_map_agreement(floor - 1),
&ctx(false, false)
),
DiploDecision::Reject
);
}
#[test]
fn ironhold_rejects_shared_map_low_trade_willingness() {
// ironhold trade=3 < 5 → Reject regardless of payment
assert_eq!(
evaluate_shared_map_accept(
"ironhold",
&ironhold_axes(),
&shared_map_agreement(200),
&ctx(false, false)
),
DiploDecision::Reject
);
}
#[test]
fn runesmith_accepts_shared_map_when_payment_meets_floor() {
// runesmith trade=7 >= 5. floor = 40 + 8*(5-7).max(0) = 40
let floor = 40;
assert_eq!(
evaluate_shared_map_accept(
"runesmith",
&runesmith_axes(),
&shared_map_agreement(floor),
&ctx(false, false)
),
DiploDecision::Accept
);
assert_eq!(
evaluate_shared_map_accept(
"runesmith",
&runesmith_axes(),
&shared_map_agreement(floor - 1),
&ctx(false, false)
),
DiploDecision::Reject
);
}
// ── SharedMap offer ───────────────────────────────────────────────────────
#[test]
fn goldvein_offers_shared_map() {
assert_eq!(
evaluate_shared_map_offer("goldvein", &goldvein_axes(), &ctx(false, false)),
DiploDecision::Accept
);
assert_eq!(
evaluate_shared_map_offer("goldvein", &goldvein_axes(), &ctx(false, true)),
DiploDecision::Accept
);
}
#[test]
fn deepforge_does_not_offer_shared_map() {
assert_eq!(
evaluate_shared_map_offer("deepforge", &deepforge_axes(), &ctx(false, false)),
DiploDecision::Reject
);
}
#[test]
fn blackhammer_does_not_offer_shared_map() {
assert_eq!(
evaluate_shared_map_offer("blackhammer", &blackhammer_axes(), &ctx(true, false)),
DiploDecision::Reject
);
}
#[test]
fn ironhold_does_not_offer_shared_map_low_trade() {
// trade=3 < 6
assert_eq!(
evaluate_shared_map_offer("ironhold", &ironhold_axes(), &ctx(false, false)),
DiploDecision::Reject
);
}
#[test]
fn runesmith_offers_shared_map() {
// trade=7 >= 6
assert_eq!(
evaluate_shared_map_offer("runesmith", &runesmith_axes(), &ctx(false, false)),
DiploDecision::Accept
);
}
}

View file

@ -6,6 +6,7 @@
//! leaf-value evaluator used by the tournament-mode strategy search.
pub mod abstract_state;
pub mod diplomacy;
pub mod evaluator;
pub mod game_state;
pub mod gpu;
@ -16,6 +17,10 @@ pub mod rollout;
pub mod tactical;
pub use abstract_state::{AbstractPlayerState, AbstractRolloutState, MAX_PLAYERS};
pub use diplomacy::{
evaluate_open_borders_accept, evaluate_open_borders_offer, evaluate_shared_map_accept,
evaluate_shared_map_offer, DiploDecision, DiplomacyCtx,
};
pub use evaluator::{LoadError, PersonalityDef, ScoringWeights};
pub use gpu::{
batch_simulate, batch_simulate_cpu, batch_simulate_cpu_default_horizon,