diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs index f9078294..4611bc2a 100644 --- a/src/simulator/crates/mc-ai/src/tactical/mod.rs +++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs @@ -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; diff --git a/src/simulator/crates/mc-ai/src/tactical/ransom_decision.rs b/src/simulator/crates/mc-ai/src/tactical/ransom_decision.rs new file mode 100644 index 00000000..c351693c --- /dev/null +++ b/src/simulator/crates/mc-ai/src/tactical/ransom_decision.rs @@ -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 `/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::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( + offers: I, + self_gold: i32, + self_priors: &PersonalityPriors, +) -> Vec +where + I: IntoIterator, +{ + 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()); + } +}