feat(@projects/@magic-civilization): ✨ add ransom decision module
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fe5db2d25f
commit
bf51c91259
2 changed files with 131 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
130
src/simulator/crates/mc-ai/src/tactical/ransom_decision.rs
Normal file
130
src/simulator/crates/mc-ai/src/tactical/ransom_decision.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue