feat(@projects/@magic-civilization): add ransom decision module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-13 16:26:45 -07:00
parent fe5db2d25f
commit bf51c91259
2 changed files with 131 additions and 0 deletions

View file

@ -37,6 +37,7 @@ pub mod combat_predict;
pub(crate) mod movement;
pub(crate) mod production;
pub mod promotion;
pub mod ransom_decision;
pub mod scoring;
pub(crate) mod settle;
pub mod state;

View file

@ -0,0 +1,130 @@
//! p2-55d — AI ransom accept/refuse decision shim used by mc-turn's
//! start-of-turn hook.
//!
//! `mc-ai::policy::decide_ransom_response` is the pure decision function and
//! ships with `PersonalityPriors` already loaded. This module exposes a thin
//! convenience surface for the mc-turn caller so processor.rs does not need to
//! re-implement priors loading inline. All I/O is fenced behind explicit
//! function calls — the decision function itself stays pure.
//!
//! ## Surface
//!
//! * [`priors_for_clan`] — load a clan's `PersonalityPriors` from a data dir.
//! Errors swallowed to `None` so the caller can fall back to neutral
//! defaults without panicking on a malformed data pack.
//! * [`PendingDecision`] / [`decide_for_offers`] — apply
//! `decide_ransom_response` over an iterator of offers using a caller-
//! supplied priors lookup, returning a small Vec the caller drains to apply
//! gold deductions and ownership flips.
//!
//! The module is file-disjoint from [`crate::policy`] per p2-55d's edit
//! charter — capture/ransom core scoring stays in `policy.rs`, the
//! mc-turn-facing helper lives here.
use std::path::Path;
use crate::policy::{decide_ransom_response, PersonalityPriors, RansomDecision};
/// Load `PersonalityPriors` for `clan_id` from `<data_dir>/ai_personalities.json`.
///
/// Returns `None` when the data file is missing, malformed, or does not
/// contain the clan id. Callers (mc-turn::processor) treat `None` as
/// "fall back to `PersonalityPriors::default()`", which keeps the hook
/// quiet in tests that do not pin a data dir.
pub fn priors_for_clan(clan_id: &str, data_dir: &Path) -> Option<PersonalityPriors> {
PersonalityPriors::from_personality(clan_id, data_dir).ok()
}
/// Result of applying [`decide_ransom_response`] to a single pending offer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PendingDecision {
/// Offer id, copied verbatim from the `RansomOffer` so the caller can
/// route into `RansomQueue::accept(id)` / `refuse(id)` without re-zipping.
pub offer_id: u32,
/// `true` when the AI chose to accept AND has sufficient gold. `false`
/// for `RansomDecision::Refuse` or `Accept` with insufficient gold.
pub accept: bool,
}
/// Apply [`decide_ransom_response`] to every `(offer_id, price)` in `offers`
/// using `self_gold` and `self_priors`. Returns a verdict per offer in input
/// order. Pure — no I/O, no logging. The caller in mc-turn::processor owns
/// the queue/state mutation and event emission.
///
/// `offers` is `(offer_id, price)` rather than a `&RansomOffer` slice to keep
/// the mc-ai crate from importing `mc_turn::ransom::RansomOffer` (which would
/// create a reverse dep). The caller pre-projects.
pub fn decide_for_offers<I>(
offers: I,
self_gold: i32,
self_priors: &PersonalityPriors,
) -> Vec<PendingDecision>
where
I: IntoIterator<Item = (u32, i32)>,
{
offers
.into_iter()
.map(|(offer_id, price)| {
let verdict = decide_ransom_response(price, self_gold, self_priors);
PendingDecision {
offer_id,
accept: matches!(verdict, RansomDecision::Accept) && self_gold >= price,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn merchant_priors() -> PersonalityPriors {
// ransom_accept_threshold 0.9 → accept-at-sufficient-gold fires
// (0.85 * 0.9 = 0.765 ≥ 0.5).
PersonalityPriors {
ransom_accept_threshold: 0.9,
..PersonalityPriors::default()
}
}
fn raider_priors() -> PersonalityPriors {
// ransom_accept_threshold 0.3 → 0.85 * 0.3 = 0.255 < 0.5 → refuse.
PersonalityPriors {
ransom_accept_threshold: 0.3,
..PersonalityPriors::default()
}
}
#[test]
fn merchant_accepts_when_gold_sufficient() {
let decisions = decide_for_offers([(7, 50)], 200, &merchant_priors());
assert_eq!(decisions.len(), 1);
assert_eq!(decisions[0].offer_id, 7);
assert!(decisions[0].accept, "merchant with 200g must accept 50g offer");
}
#[test]
fn raider_refuses_even_when_solvent() {
let decisions = decide_for_offers([(7, 50)], 200, &raider_priors());
assert!(!decisions[0].accept, "raider threshold 0.3 yields refuse");
}
#[test]
fn insufficient_gold_forces_refuse_regardless_of_priors() {
let decisions = decide_for_offers([(7, 500)], 10, &merchant_priors());
assert!(!decisions[0].accept, "below-price gold always refuses");
}
#[test]
fn empty_offers_yields_empty_decisions() {
let decisions = decide_for_offers(std::iter::empty(), 100, &merchant_priors());
assert!(decisions.is_empty());
}
#[test]
fn priors_for_unknown_clan_returns_none_without_panic() {
// No data dir on disk for this test — just exercise the None path.
let priors = priors_for_clan("nonexistent_clan_xyz", Path::new("/no/such/dir"));
assert!(priors.is_none());
}
}