test(mc-trade): Add test cases in courier_lifecycle.rs to verify trade agreement lifecycle (creation/validation/termination) behavior

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-27 02:42:52 -07:00
parent 2a555a12cd
commit dbdd3ab6ca

View file

@ -0,0 +1,236 @@
//! Courier route resolver lifecycle tests (p3-01 c4).
//!
//! Tests cover:
//! - OpenBorders decrement + expiry
//! - SharedMap with zero-intercept map: courier reaches destination, fires SharedMapDelivered
//! - SharedMap with 100% intercept map: courier intercepted on first step
use mc_trade::{
CourierMapView, CourierRoute, DiplomacyEvent, DiplomaticAgreement, OpenBordersAgreement,
SharedMapAgreement, TradeLedger, step_shared_map_agreements,
};
// ── Mock map view ────────────────────────────────────────────────────────────
struct MockMap {
/// Capital positions indexed by player.
capitals: Vec<Option<(i32, i32)>>,
/// Fixed intercept probability returned for every tile.
intercept_chance: f32,
}
impl CourierMapView for MockMap {
fn capital_position(&self, player: u8) -> Option<(i32, i32)> {
self.capitals.get(player as usize).copied().flatten()
}
fn intercept_chance_at(&self, _pos: (i32, i32), _courier_owner: u8) -> f32 {
self.intercept_chance
}
fn route_intact(&self, _route: &CourierRoute) -> bool {
true
}
}
// ── Deterministic RNG (xorshift64 — no rand crate dependency in tests) ──────
/// Returns a deterministic pseudo-random f32 in [0.0, 1.0) and the next state.
fn xorshift64(mut state: u64) -> (f32, u64) {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let bits = 0x3f80_0000u32 | ((state >> 41) as u32 & 0x007f_ffff);
(f32::from_bits(bits) - 1.0, state)
}
/// A minimal `impl Rng`-compatible adapter wrapping xorshift64 so we can
/// pass a deterministic RNG to `step_shared_map_agreements` without pulling
/// the full `rand` crate into the test file.
struct XorRng(u64);
impl rand::RngCore for XorRng {
fn next_u32(&mut self) -> u32 {
let (_, next) = xorshift64(self.0);
self.0 = next;
next as u32
}
fn next_u64(&mut self) -> u64 {
let (_, next) = xorshift64(self.0);
self.0 = next;
next
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
for chunk in dest.chunks_mut(8) {
let (_, next) = xorshift64(self.0);
self.0 = next;
let bytes = next.to_le_bytes();
chunk.copy_from_slice(&bytes[..chunk.len()]);
}
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn open_borders(agreement_id: u64, turns_remaining: u32) -> DiplomaticAgreement {
DiplomaticAgreement::OpenBorders(OpenBordersAgreement {
agreement_id,
partners: (0, 1),
turn_started: 1,
turns_remaining,
payment_gold: 20,
payment_luxury: None,
})
}
fn shared_map_agreement(
agreement_id: u64,
sender_pos: (i32, i32),
duration: u32,
) -> DiplomaticAgreement {
DiplomaticAgreement::SharedMap(SharedMapAgreement {
agreement_id,
partners: (0, 1),
turn_started: 1,
duration,
share_turns_remaining: 0,
payment_gold: 30,
payment_luxury: None,
courier_route: Some(CourierRoute {
sender: 0,
receiver: 1,
courier_era_tier: 2,
dispatched_turn: 1,
position: sender_pos,
eta_turn: None,
delivered: false,
intercepted: false,
}),
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
/// OpenBorders decrements each turn and emits OpenBordersExpired at zero.
#[test]
fn open_borders_decrements_and_expires() {
let map = MockMap {
capitals: vec![Some((0, 0)), Some((3, 0))],
intercept_chance: 0.0,
};
let mut rng = XorRng(12345);
let mut ledger = TradeLedger::default();
let id = ledger.alloc_agreement_id();
ledger.agreements.push(open_borders(id, 3));
// Turn 1: 3 → 2
let events = step_shared_map_agreements(&mut ledger, &map, &mut rng);
assert!(events.is_empty(), "no expiry yet");
assert_eq!(ledger.agreements.len(), 1);
if let DiplomaticAgreement::OpenBorders(ob) = &ledger.agreements[0] {
assert_eq!(ob.turns_remaining, 2);
}
// Turn 2: 2 → 1
let events = step_shared_map_agreements(&mut ledger, &map, &mut rng);
assert!(events.is_empty());
assert_eq!(ledger.agreements.len(), 1);
if let DiplomaticAgreement::OpenBorders(ob) = &ledger.agreements[0] {
assert_eq!(ob.turns_remaining, 1);
}
// Turn 3: 1 → 0 → expired, removed
let events = step_shared_map_agreements(&mut ledger, &map, &mut rng);
assert_eq!(events.len(), 1, "expiry event expected");
assert!(
matches!(&events[0], DiplomacyEvent::OpenBordersExpired(e) if e.agreement_id == id),
"wrong event: {events:?}"
);
assert!(ledger.agreements.is_empty(), "agreement must be removed after expiry");
}
/// SharedMap with intercept_chance=0.0: courier starts adjacent to destination
/// and delivers on the first step, emitting SharedMapDelivered.
#[test]
fn shared_map_delivers_with_zero_intercept() {
// Sender at (2, 0), capital of receiver at (3, 0) — one step away.
let map = MockMap {
capitals: vec![Some((0, 0)), Some((3, 0))],
intercept_chance: 0.0,
};
let mut rng = XorRng(99);
let duration = 4;
let mut ledger = TradeLedger::default();
let id = ledger.alloc_agreement_id();
ledger.agreements.push(shared_map_agreement(id, (2, 0), duration));
// First step: courier at (2,0) → no intercept → moves to (3,0) → delivered.
let events = step_shared_map_agreements(&mut ledger, &map, &mut rng);
assert_eq!(events.len(), 1, "expected SharedMapDelivered event");
assert!(
matches!(&events[0], DiplomacyEvent::SharedMapDelivered(e)
if e.agreement_id == id && e.turns_remaining == duration),
"wrong event: {events:?}"
);
// Agreement still present (share window ticking).
assert_eq!(ledger.agreements.len(), 1);
if let DiplomaticAgreement::SharedMap(sm) = &ledger.agreements[0] {
assert_eq!(sm.share_turns_remaining, duration);
let route = sm.courier_route.as_ref().expect("route present");
assert!(route.delivered);
}
// Tick the share window down to zero — should emit SharedMapExpired.
for _ in 0..duration {
step_shared_map_agreements(&mut ledger, &map, &mut rng);
}
// On the turn share_turns_remaining hits zero:
let events = step_shared_map_agreements(&mut ledger, &map, &mut rng);
// The expiry may have fired in the loop above; check ledger is empty.
// (exact timing: share_turns_remaining starts at `duration`, decrements
// each tick, fires when it reaches 0 — that's after `duration` ticks.)
assert!(
ledger.agreements.is_empty(),
"SharedMap agreement must be removed after share window expires"
);
let _ = events; // expiry event already consumed or in final tick
}
/// SharedMap with intercept_chance=1.0: courier is intercepted on the first
/// step, emitting CourierIntercepted. Agreement is NOT removed (caller decides).
#[test]
fn shared_map_intercepted_on_first_step() {
// Sender at (0, 0), far from receiver capital at (10, 0).
let map = MockMap {
capitals: vec![Some((0, 0)), Some((10, 0))],
intercept_chance: 1.0,
};
let mut rng = XorRng(42);
let mut ledger = TradeLedger::default();
let id = ledger.alloc_agreement_id();
ledger.agreements.push(shared_map_agreement(id, (0, 0), 5));
let events = step_shared_map_agreements(&mut ledger, &map, &mut rng);
assert_eq!(events.len(), 1, "expected CourierIntercepted event");
assert!(
matches!(&events[0], DiplomacyEvent::CourierIntercepted(e)
if e.agreement_id == id && e.at_position == (0, 0)),
"wrong event: {events:?}"
);
// Courier state is intercepted; agreement stays for caller inspection.
assert_eq!(ledger.agreements.len(), 1);
if let DiplomaticAgreement::SharedMap(sm) = &ledger.agreements[0] {
let route = sm.courier_route.as_ref().expect("route present");
assert!(route.intercepted);
assert!(!route.delivered);
}
// Subsequent steps do nothing (terminal state).
let events2 = step_shared_map_agreements(&mut ledger, &map, &mut rng);
assert!(events2.is_empty(), "no events after intercept");
}