diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 64b55397..a411fee5 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -2275,8 +2275,14 @@ impl TurnProcessor { let owner = defender_player as u8; let created_turn = state.turn; - let offer_id = state.ransom_queue.push(unit_id, captor, owner, price, created_turn); - let expires_turn = created_turn.saturating_add(crate::ransom::RANSOM_OFFER_DURATION_TURNS); + // p2-55f: use the data-driven duration from CombatBalance instead of + // the hardcoded const. The const remains as a fallback default for + // tests that construct GameState via Default and never load JSON. + let duration = state.combat_balance.ransom_offer_duration_turns; + let offer_id = state + .ransom_queue + .push_with_duration(unit_id, captor, owner, price, created_turn, duration); + let expires_turn = created_turn.saturating_add(duration); state.pending_capture_events.ransom_offers_created.push(UnitRansomOfferedEvent { turn: state.turn, diff --git a/src/simulator/crates/mc-turn/tests/ransom.rs b/src/simulator/crates/mc-turn/tests/ransom.rs index 38507291..62fbe5f7 100644 --- a/src/simulator/crates/mc-turn/tests/ransom.rs +++ b/src/simulator/crates/mc-turn/tests/ransom.rs @@ -177,3 +177,40 @@ fn pending_capture_events_is_empty_considers_new_vecs() { }); assert!(!pending.is_empty(), "non-empty expired should report not-empty"); } + +// ── p2-55f: end-to-end duration plumbed from CombatBalance ──────────────────── + +#[test] +fn push_with_duration_overrides_default_const() { + // p2-55f: RansomQueue::push_with_duration accepts a runtime duration, + // proving the const is no longer the only path. CombatBalance.ransom_offer_duration_turns + // flows through here when wired in processor.rs::enqueue_ransom_offer. + let mut q = RansomQueue::default(); + let id = q.push_with_duration(101, 1, 2, 50, /*created*/ 10, /*duration*/ 5); + + let offer = q.iter().find(|o| o.id == id).expect("offer present"); + assert_eq!(offer.expires_turn, 15, "expires_turn = created + duration"); + + // Tick at turn 14 — not yet expired. + let early = q.tick(14); + assert!(early.is_empty(), "duration=5 means turn 14 is one short of expiry"); + + // Tick at turn 15 — drained. + let expired = q.tick(15); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].unit_id, 101); +} + +#[test] +fn combat_balance_default_matches_legacy_duration_const() { + // p2-55f: CombatBalance::default().ransom_offer_duration_turns must equal + // RANSOM_OFFER_DURATION_TURNS so behaviour is byte-equivalent for code + // paths that haven't migrated to read from CombatBalance yet. + use mc_turn::combat_balance::CombatBalance; + let cb = CombatBalance::default(); + assert_eq!( + cb.ransom_offer_duration_turns, + RANSOM_OFFER_DURATION_TURNS, + "default config drift would break unmigrated callers" + ); +}