From afa600849ba24574fb17aa0f9e425f87b08e2878 Mon Sep 17 00:00:00 2001 From: autocommit Date: Tue, 28 Apr 2026 15:29:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(mc-ai):=20=E2=9C=A8=20Introduce=20diplomac?= =?UTF-8?q?y=20evaluation=20logic=20with=20evaluate=5Fopen=5Fborder=20and?= =?UTF-8?q?=20handle=5Fshared=5Fmap=5Foffer=20functions,=20integrate=20tra?= =?UTF-8?q?de=20mechanics,=20and=20update=20dependencies=20for=20MC-trade?= =?UTF-8?q?=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-ai/src/diplomacy.rs | 533 ++++++++++++++++++++ src/simulator/crates/mc-ai/src/lib.rs | 5 + 2 files changed, 538 insertions(+) create mode 100644 src/simulator/crates/mc-ai/src/diplomacy.rs diff --git a/src/simulator/crates/mc-ai/src/diplomacy.rs b/src/simulator/crates/mc-ai/src/diplomacy.rs new file mode 100644 index 00000000..8ad8bed1 --- /dev/null +++ b/src/simulator/crates/mc-ai/src/diplomacy.rs @@ -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) -> 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, + _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, + 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, + 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, + 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 { + pairs.iter().map(|&(k, v)| (k.to_string(), v)).collect() + } + + fn goldvein_axes() -> HashMap { + axes(&[ + ("aggression", 3), + ("expansion", 5), + ("production", 5), + ("wealth", 9), + ("trade_willingness", 9), + ("grudge_persistence", 4), + ]) + } + + fn deepforge_axes() -> HashMap { + axes(&[ + ("aggression", 4), + ("expansion", 2), + ("production", 8), + ("wealth", 5), + ("trade_willingness", 4), + ("grudge_persistence", 6), + ]) + } + + fn blackhammer_axes() -> HashMap { + axes(&[ + ("aggression", 9), + ("expansion", 6), + ("production", 7), + ("wealth", 2), + ("trade_willingness", 2), + ("grudge_persistence", 9), + ]) + } + + fn ironhold_axes() -> HashMap { + axes(&[ + ("aggression", 6), + ("expansion", 4), + ("production", 9), + ("wealth", 3), + ("trade_willingness", 3), + ("grudge_persistence", 7), + ]) + } + + fn runesmith_axes() -> HashMap { + 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 + ); + } +} diff --git a/src/simulator/crates/mc-ai/src/lib.rs b/src/simulator/crates/mc-ai/src/lib.rs index d675ce5a..21646098 100644 --- a/src/simulator/crates/mc-ai/src/lib.rs +++ b/src/simulator/crates/mc-ai/src/lib.rs @@ -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,