diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index ca932ef4..603ab576 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -12,7 +12,9 @@ //! - **Targeted unit verbs** (`Move`, `Attack`, `RangedAttack`) → //! enqueued onto the matching `pending_*` request vector on //! `GameState`. The turn processor drains those queues during -//! end-of-turn resolution. +//! end-of-turn resolution. `Attack` and `RangedAttack` share the +//! `pending_pvp_attacks` queue (discriminated by `AttackRequest.is_ranged`) +//! so both resolve through the one `mc_combat::CombatResolver` site. //! - **`EndTurn`** → increments `GameState.turn` and emits a //! `TurnEnded` / `TurnStarted` event pair. //! - **`Noop`** → no state change, no events. @@ -97,11 +99,9 @@ pub fn apply_action( PlayerAction::Attack { unit_id, target } => { apply_attack(state, player, unit_id, *target) } - PlayerAction::RangedAttack { .. } => Err(ActionError::NotYetImplemented { - message: "ranged_attack queueing pending mc-turn pending_volley wiring \ - (TRACKED: p2-67 Phase 1 follow-up)" - .into(), - }), + PlayerAction::RangedAttack { unit_id, target } => { + apply_ranged_attack(state, player, unit_id, *target) + } // ── Empire-level: civic / diplomacy / ransom ───────────────────── PlayerAction::SwitchCivic { axis, choice } => { @@ -2031,12 +2031,103 @@ fn apply_attack( attacker_unit, defender_player: defender_player as u8, defender_unit, + is_ranged: false, }); // Attack resolution happens during end-of-turn processing. No // synchronous events at queue time. Ok(Vec::new()) } +/// Rail-1 Phase 1 — `RangedAttack` dispatch. Mirrors [`apply_attack`] but +/// for ranged units (archer range 2; ballista / catapult / cannon 3; +/// apex_artillery 6 — `public/resources/units/*.json`). +/// +/// Differences from melee, all faithful to the live `combat_resolver.gd` +/// ranged path (which resolves immediately, not via a multi-phase volley +/// queue — the volley queue is a *separate* AoE action): +/// +/// * **Range gate** — the target must be within the attacker's `range` +/// (read from `units_catalog`, not adjacency). A unit whose catalog +/// `range == 0` (a melee unit) cannot ranged-attack. +/// * **No retaliation** — the queued request carries `is_ranged: true`, +/// so `resolve_single_pvp_attack_typed` resolves it as +/// `CombatType::Ranged`; `mc_combat::prevents_retaliation` then +/// suppresses the defender's counter-attack. +/// * **No advance** — the attacker stays on its hex; a ranged hit never +/// moves the attacker onto the target tile. +/// +/// Resolution itself is shared with melee: the request drains through the +/// same `process_pvp_combat` → `resolve_single_pvp_attack_typed` +/// (`mc_combat::CombatResolver::resolve`) path, so ranged support bonuses, +/// XP, capture, and `UnitKilled` surfacing are identical to the melee +/// path. No movement is spent — the bench melee path likewise queues +/// without touching `movement_remaining`; spending it only on ranged would +/// be a non-parity divergence. +fn apply_ranged_attack( + state: &mut GameState, + player: PlayerId, + unit_id: &str, + target: WireHex, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (attacker_player, attacker_unit) = find_unit_indices(state, unit_u32)?; + // Only the unit's own owner may order it to fire. + if attacker_player as PlayerId != player { + return Err(ActionError::IllegalAction { + message: format!("unit {unit_id} is not owned by player {player}"), + }); + } + // Source the attacker's `range` from the catalog (real authored data, + // not a hardcode). A `range == 0` unit is melee-only and cannot fire. + let (attacker_col, attacker_row, attacker_kind) = { + let u = &state.players[attacker_player].units[attacker_unit]; + (u.col, u.row, u.unit_id.clone()) + }; + let range = state + .units_catalog + .get(&attacker_kind) + .map(|s| s.combat.range) + .unwrap_or(0); + if range <= 0 { + return Err(ActionError::IllegalAction { + message: format!("unit {unit_id} ({attacker_kind}) has no ranged attack"), + }); + } + // Locate the defender at the target hex; it must be an enemy. + let (defender_player, defender_unit) = + find_unit_at_hex(state, target).ok_or_else(|| ActionError::TargetInvalid { + message: format!("no defender at hex [{}, {}]", target[0], target[1]), + })?; + if defender_player == attacker_player { + return Err(ActionError::IllegalAction { + message: format!( + "ranged_attack target at [{}, {}] is friendly", + target[0], target[1] + ), + }); + } + // Range gate — offset-grid Chebyshev distance, matching the radius + // checks the processor uses (`hex_distance`). + let dist = (attacker_col - target[0]).abs().max((attacker_row - target[1]).abs()); + if dist > range { + return Err(ActionError::TargetInvalid { + message: format!( + "target at [{}, {}] is {} hexes away; unit {unit_id} range is {}", + target[0], target[1], dist, range + ), + }); + } + state.pending_pvp_attacks.push(AttackRequest { + attacker_player: attacker_player as u8, + attacker_unit, + defender_player: defender_player as u8, + defender_unit, + is_ranged: true, + }); + // Resolution happens during end-of-turn processing — same as melee. + Ok(Vec::new()) +} + fn find_unit_at_hex(state: &GameState, hex: WireHex) -> Option<(usize, usize)> { for (p_idx, player) in state.players.iter().enumerate() { if let Some(u_idx) = player.units.iter().position(|u| u.col == hex[0] && u.row == hex[1]) { diff --git a/src/simulator/crates/mc-player-api/tests/ranged_attack_no_retaliation.rs b/src/simulator/crates/mc-player-api/tests/ranged_attack_no_retaliation.rs new file mode 100644 index 00000000..02be0162 --- /dev/null +++ b/src/simulator/crates/mc-player-api/tests/ranged_attack_no_retaliation.rs @@ -0,0 +1,205 @@ +//! Rail-1 Phase 1 — `RangedAttack` dispatch range-gate + queue contract. +//! +//! This is the *dispatch-layer* half of the RangedAttack proof: a ranged +//! unit at range 2 fires successfully (the gate reads the unit's authored +//! `range`, not adjacency), queues a `pending_pvp_attacks` request flagged +//! `is_ranged`, and rejects out-of-range / melee-only / friendly-fire cases. +//! +//! The *resolution* half (defender takes damage, attacker takes NO +//! retaliation) is proved in +//! `mc-turn/tests/ranged_pvp_no_retaliation.rs`, which drives +//! `resolve_single_pvp_attack_typed` directly so the assertion is a clean +//! function of the combat resolver rather than a full turn `step`. + +use mc_player_api::action::PlayerAction; +use mc_player_api::apply_action; +use mc_player_api::error::ActionError; +use mc_state::game_state::{GameState, MapUnit, PlayerState}; + +/// Build a 2-player state with a catalog carrying one ranged archer +/// (range 2, ranged_attack 12 — mirrors `public/resources/units/archer.json`) +/// and one melee warrior (range 0). +fn base_state() -> GameState { + let mut state = GameState { + turn: 0, + players: vec![ + PlayerState { + player_index: 0, + ..Default::default() + }, + PlayerState { + player_index: 1, + ..Default::default() + }, + ], + grid: None, + ..Default::default() + }; + state + .units_catalog + .load_json_str( + r#"[ + { "id": "archer", "movement": 2, "domain": "land", + "hp": 40, "attack": 5, "defense": 1, + "ranged_attack": 12, "range": 2 }, + { "id": "dwarf_warrior", "movement": 2, "domain": "land", + "hp": 60, "attack": 12, "defense": 1, + "ranged_attack": 0, "range": 0 } + ]"#, + ) + .expect("catalog json parses"); + state +} + +#[test] +fn ranged_attack_at_range_2_queues_ranged_request() { + let mut state = base_state(); + + // p0 archer at (0, 0). + state.players[0].units.push(MapUnit { + id: 10, + col: 0, + row: 0, + hp: 40, + max_hp: 40, + attack: 5, + defense: 1, + unit_id: "archer".to_string(), + ..Default::default() + }); + // p1 warrior at (2, 0) — Chebyshev distance 2, inside archer range. + state.players[1].units.push(MapUnit { + id: 20, + col: 2, + row: 0, + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + + // Queue the ranged attack — no synchronous events at queue time + // (resolution drains in the processor step, same as melee). + let queue_events = apply_action( + &mut state, + 0, + &PlayerAction::RangedAttack { + unit_id: "10".to_string(), + target: [2, 0], + }, + ) + .expect("RangedAttack at range 2 must dispatch cleanly"); + assert!( + queue_events.is_empty(), + "RangedAttack queues an AttackRequest; no synchronous events. got: {queue_events:?}" + ); + assert_eq!( + state.pending_pvp_attacks.len(), + 1, + "the ranged attack request must be queued" + ); + let req = &state.pending_pvp_attacks[0]; + assert!(req.is_ranged, "queued request must be flagged ranged"); + assert_eq!( + (req.attacker_player, req.defender_player), + (0, 1), + "request must target the enemy at the hex" + ); + // The attacker is untouched at queue time — no advance, no movement spend. + let attacker = &state.players[0].units[0]; + assert_eq!( + (attacker.col, attacker.row), + (0, 0), + "ranged attacker must NOT advance onto the target hex" + ); +} + +#[test] +fn ranged_attack_beyond_range_is_rejected() { + let mut state = base_state(); + state.players[0].units.push(MapUnit { + id: 10, + col: 0, + row: 0, + hp: 40, + max_hp: 40, + attack: 5, + defense: 1, + unit_id: "archer".to_string(), + ..Default::default() + }); + // Enemy at (5, 0) — distance 5, well beyond archer range 2. + state.players[1].units.push(MapUnit { + id: 20, + col: 5, + row: 0, + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + + let err = apply_action( + &mut state, + 0, + &PlayerAction::RangedAttack { + unit_id: "10".to_string(), + target: [5, 0], + }, + ) + .expect_err("out-of-range RangedAttack must be rejected"); + assert!( + matches!(err, ActionError::TargetInvalid { .. }), + "expected TargetInvalid, got {err:?}" + ); + assert!( + state.pending_pvp_attacks.is_empty(), + "a rejected ranged attack must not queue a request" + ); +} + +#[test] +fn melee_unit_cannot_ranged_attack() { + let mut state = base_state(); + // A warrior (catalog range 0) tries to ranged-attack an adjacent enemy. + state.players[0].units.push(MapUnit { + id: 10, + col: 0, + row: 0, + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + state.players[1].units.push(MapUnit { + id: 20, + col: 1, + row: 0, + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + + let err = apply_action( + &mut state, + 0, + &PlayerAction::RangedAttack { + unit_id: "10".to_string(), + target: [1, 0], + }, + ) + .expect_err("a range-0 unit must not be allowed to ranged-attack"); + assert!( + matches!(err, ActionError::IllegalAction { .. }), + "expected IllegalAction, got {err:?}" + ); +} diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index e9c6aac2..a8f2f5ac 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -155,6 +155,15 @@ pub struct AttackRequest { pub defender_player: u8, /// Index into `PlayerState::units` for the defending unit. pub defender_unit: usize, + /// Rail-1 Phase 1: true when this is a `RangedAttack` (target within the + /// unit's `range`, not adjacency). Drives `CombatType::Ranged` in + /// `resolve_single_pvp_attack` — the resolver then sources the attacker's + /// `ranged_attack` stat and suppresses retaliation via + /// `mc_combat::prevents_retaliation(.., combat_is_ranged=true)`. The + /// attacker does NOT advance onto the target hex. `#[serde(default)]` + /// keeps queued melee saves (no field) loading as `false` = melee. + #[serde(default)] + pub is_ranged: bool, } /// A bombard request queued by GDScript (player clicks Bombard, selects target hex). diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 8630a7e9..fe4e0d1c 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -2796,6 +2796,34 @@ impl TurnProcessor { attacker_unit: usize, defender_player: usize, defender_unit: usize, + ) -> Option { + self.resolve_single_pvp_attack_typed( + state, + attacker_player, + attacker_unit, + defender_player, + defender_unit, + false, + ) + } + + /// Rail-1 Phase 1 — combat-type-aware variant of + /// [`Self::resolve_single_pvp_attack`]. `is_ranged == true` resolves the + /// engagement as `CombatType::Ranged`: the attacker's `ranged_attack` + /// stat (sourced from `units_catalog`, since `MapUnit` carries only the + /// melee `attack`) drives damage, and `mc_combat::prevents_retaliation` + /// suppresses the defender's counter-attack (no `attacker_damage`). The + /// attacker stays on its hex — RangedAttack never advances onto the + /// target. Melee (`is_ranged == false`) is byte-identical to the legacy + /// behaviour. The public no-flag method delegates here with `false`. + pub fn resolve_single_pvp_attack_typed( + &self, + state: &mut GameState, + attacker_player: usize, + attacker_unit: usize, + defender_player: usize, + defender_unit: usize, + is_ranged: bool, ) -> Option { if attacker_player == defender_player { return None; } if attacker_player >= state.players.len() { return None; } @@ -2885,6 +2913,21 @@ impl TurnProcessor { .unwrap_or(mc_combat::PostureResolution::Capture) }; + // Rail-1 Phase 1: ranged attackers carry their punch in `ranged_attack`, + // which `MapUnit` does not store — source it (and `range`) from the + // units catalog, matching the live GDScript path + // (`combat_resolver.gd::_get_ranged_attack` + `unit.get_range()`). + let (a_ranged_attack, a_range) = if is_ranged { + match state + .units_catalog + .get(&state.players[attacker_player].units[attacker_unit].unit_id) + { + Some(stats) => (stats.combat.ranged_attack, stats.combat.range), + None => (0, 0), + } + } else { + (0, 0) + }; let params = { let defender = &state.players[defender_player].units[defender_unit]; // p3-26 B6b: equipped-item combat bonuses (0 for unequipped units). @@ -2896,14 +2939,19 @@ impl TurnProcessor { CombatParams { attacker: UnitStats { hp: a_hp, max_hp: 60, attack: a_atk + a_eq_atk, - defense: a_def + a_eq_def, ranged_attack: 0, range: 0, movement: 2, + defense: a_def + a_eq_def, + // Ranged attackers add their equipped attack bonus onto the + // ranged channel (the resolver reads `ranged_attack` when + // `combat_type == Ranged`); melee keeps these zeroed. + ranged_attack: if is_ranged { a_ranged_attack + a_eq_atk } else { 0 }, + range: a_range, movement: 2, }, defender: UnitStats { hp: defender.hp, max_hp: defender.max_hp, attack: defender.attack + d_eq_atk, defense: defender.defense + d_eq_def, ranged_attack: 0, range: 0, movement: 2, }, - combat_type: CombatType::Melee, + combat_type: if is_ranged { CombatType::Ranged } else { CombatType::Melee }, attacker_keywords: Vec::new(), defender_keywords: Vec::new(), attacker_bonuses: CombatBonuses::default(), @@ -3559,12 +3607,13 @@ impl TurnProcessor { let queued: Vec = std::mem::take(&mut state.pending_pvp_attacks); for req in queued { - if let Some(ev) = self.resolve_single_pvp_attack( + if let Some(ev) = self.resolve_single_pvp_attack_typed( state, req.attacker_player as usize, req.attacker_unit, req.defender_player as usize, req.defender_unit, + req.is_ranged, ) { result.pvp_battles += 1; if !ev.attacker_survived { result.pvp_kills += 1; } @@ -5774,6 +5823,7 @@ mod move_request_tests { attacker_unit: 0, defender_player: 1, defender_unit: 0, + is_ranged: false, }); let processor = TurnProcessor::new(500); let mut result = TurnResult::default(); diff --git a/src/simulator/crates/mc-turn/tests/capture_caravan.rs b/src/simulator/crates/mc-turn/tests/capture_caravan.rs index cb9bdd07..8d2ef317 100644 --- a/src/simulator/crates/mc-turn/tests/capture_caravan.rs +++ b/src/simulator/crates/mc-turn/tests/capture_caravan.rs @@ -121,6 +121,7 @@ fn fixture(posture: CapturePosture, defender_unit_id: &str, defender_id: u32, ap attacker_unit: 0, defender_player: 1, defender_unit: 0, + is_ranged: false, }); state } diff --git a/src/simulator/crates/mc-turn/tests/capture_engineer.rs b/src/simulator/crates/mc-turn/tests/capture_engineer.rs index a189b4a9..dc4898af 100644 --- a/src/simulator/crates/mc-turn/tests/capture_engineer.rs +++ b/src/simulator/crates/mc-turn/tests/capture_engineer.rs @@ -116,6 +116,7 @@ fn fixture_with_posture(posture: CapturePosture, eng_ap_current: u8) -> GameStat attacker_unit: 0, defender_player: 1, defender_unit: 0, + is_ranged: false, }); state } diff --git a/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs b/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs index d3d65fdc..cd80d246 100644 --- a/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs +++ b/src/simulator/crates/mc-turn/tests/capture_pvp_end_to_end.rs @@ -125,6 +125,7 @@ fn fixture_with_posture(posture: CapturePosture) -> GameState { attacker_unit: 0, defender_player: 1, defender_unit: 0, + is_ranged: false, }); state diff --git a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs index c1aac435..4b03bfb6 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -146,6 +146,7 @@ fn fixture_state() -> GameState { attacker_unit: 3, // 4th unit on p0 (3 siegers + this attacker) defender_player: 1, defender_unit: 0, + is_ranged: false, }); state diff --git a/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs b/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs index 077cab0f..6926ad6f 100644 --- a/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs +++ b/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs @@ -78,6 +78,7 @@ fn pvp_combat_awards_xp_to_survivors() { attacker_unit: 0, defender_player: 1, defender_unit: 0, + is_ranged: false, }); let processor = TurnProcessor::new(400); diff --git a/src/simulator/crates/mc-turn/tests/queued_pvp_unit_killed.rs b/src/simulator/crates/mc-turn/tests/queued_pvp_unit_killed.rs index 5c730605..3820556d 100644 --- a/src/simulator/crates/mc-turn/tests/queued_pvp_unit_killed.rs +++ b/src/simulator/crates/mc-turn/tests/queued_pvp_unit_killed.rs @@ -74,6 +74,7 @@ fn queued_pvp_kill_emits_unit_killed_event() { attacker_unit: 0, defender_player: 1, defender_unit: 0, + is_ranged: false, }); let processor = TurnProcessor::new(400); diff --git a/src/simulator/crates/mc-turn/tests/ranged_pvp_no_retaliation.rs b/src/simulator/crates/mc-turn/tests/ranged_pvp_no_retaliation.rs new file mode 100644 index 00000000..32d466e0 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/ranged_pvp_no_retaliation.rs @@ -0,0 +1,104 @@ +//! Rail-1 Phase 1 — `resolve_single_pvp_attack_typed(.., is_ranged = true)` +//! resolves through the shared `mc_combat::CombatResolver` as +//! `CombatType::Ranged`: the defender takes damage, and the attacker takes +//! NO retaliation (ranged suppresses the counter-attack). Pairs with the +//! dispatch-layer range-gate test in +//! `mc-player-api/tests/ranged_attack_no_retaliation.rs`. +//! +//! Resolves the engagement directly (no full `step`) so the assertion is a +//! clean function of the combat resolver, not of unrelated turn phases. + +use mc_state::game_state::{GameState, MapUnit, PlayerState}; +use mc_turn::processor::TurnProcessor; + +fn two_unit_state() -> GameState { + let mut state = GameState { + turn: 1, + players: vec![ + PlayerState { player_index: 0, ..Default::default() }, + PlayerState { player_index: 1, ..Default::default() }, + ], + grid: None, + ..Default::default() + }; + // Catalog supplies the archer's ranged_attack/range (MapUnit stores only + // the melee `attack`, so the resolver sources the ranged punch here). + state + .units_catalog + .load_json_str( + r#"[ + { "id": "archer", "movement": 2, "domain": "land", + "hp": 40, "attack": 5, "defense": 1, + "ranged_attack": 12, "range": 2 } + ]"#, + ) + .expect("catalog json parses"); + // p0 archer at (0,0); p1 warrior two hexes east (within range 2). + state.players[0].units.push(MapUnit { + id: 10, col: 0, row: 0, hp: 40, max_hp: 40, attack: 5, defense: 1, + unit_id: "archer".to_string(), ..Default::default() + }); + state.players[1].units.push(MapUnit { + id: 20, col: 2, row: 0, hp: 60, max_hp: 60, attack: 12, defense: 1, + unit_id: "dwarf_warrior".to_string(), ..Default::default() + }); + state +} + +#[test] +fn ranged_resolution_damages_defender_without_retaliation() { + let mut state = two_unit_state(); + let attacker_hp_before = state.players[0].units[0].hp; + let defender_hp_before = state.players[1].units[0].hp; + + let processor = TurnProcessor::new(u32::MAX); + let ev = processor + .resolve_single_pvp_attack_typed(&mut state, 0, 0, 1, 0, true) + .expect("ranged resolution returns a combat event"); + + // Defender (still index 0 — survived) took ranged damage. + let defender = &state.players[1].units[0]; + assert!( + defender.hp < defender_hp_before, + "defender must take ranged damage: before={defender_hp_before}, after={}", + defender.hp + ); + assert!(ev.defender_damage > 0, "event must report defender damage"); + + // Attacker took NO retaliation — ranged combat prevents the counter-attack. + let attacker = &state.players[0].units[0]; + assert_eq!( + attacker.hp, attacker_hp_before, + "ranged attacker must take no retaliation: before={attacker_hp_before}, after={}", + attacker.hp + ); + assert_eq!( + ev.attacker_damage, 0, + "ranged event must report zero retaliation damage, got {}", + ev.attacker_damage + ); + // Attacker did not advance (ranged never moves onto the target). + assert_eq!((attacker.col, attacker.row), (0, 0), "ranged attacker stays put"); +} + +#[test] +fn melee_resolution_still_retaliates() { + // Regression guard: the melee path (is_ranged = false) is unchanged — + // a healthy defender retaliates. + let mut state = two_unit_state(); + // Co-locate for a melee strike and give the attacker melee punch. + state.players[1].units[0].col = 0; + let attacker_hp_before = state.players[0].units[0].hp; + + let processor = TurnProcessor::new(u32::MAX); + let ev = processor + .resolve_single_pvp_attack_typed(&mut state, 0, 0, 1, 0, false) + .expect("melee resolution returns a combat event"); + + // Defender survives the weak melee (archer attack 5 vs warrior 60hp) and + // retaliates, so the attacker loses HP. + assert!( + ev.attacker_damage > 0 && state.players[0].units[0].hp < attacker_hp_before, + "melee must still produce retaliation damage on the attacker" + ); +}