diff --git a/src/simulator/crates/mc-trade/tests/courier_lifecycle.rs b/src/simulator/crates/mc-trade/tests/courier_lifecycle.rs new file mode 100644 index 00000000..44d4770b --- /dev/null +++ b/src/simulator/crates/mc-trade/tests/courier_lifecycle.rs @@ -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>, + /// 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"); +}