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:
parent
2a555a12cd
commit
dbdd3ab6ca
1 changed files with 236 additions and 0 deletions
236
src/simulator/crates/mc-trade/tests/courier_lifecycle.rs
Normal file
236
src/simulator/crates/mc-trade/tests/courier_lifecycle.rs
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue