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:
parent
b882a6844c
commit
afa600849b
2 changed files with 538 additions and 0 deletions
533
src/simulator/crates/mc-ai/src/diplomacy.rs
Normal file
533
src/simulator/crates/mc-ai/src/diplomacy.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue