feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — RangedAttack dispatch (completes unit input for the live store)
The live unit store (GdGameState.apply_action_json → inner) handled melee but RangedAttack was NotYetImplemented. Wire it by reusing the melee resolver: split resolve_single_pvp_attack into resolve_single_pvp_attack_typed(.., is_ranged); ranged sets CombatType::Ranged → sources ranged_attack/range from units_catalog and the resolver's prevents_retaliation(combat_is_ranged=true) suppresses the counter-attack. Did NOT reuse the crude pending_volley AoE (separate Volley action); verified live parity is immediate-resolve (combat_resolver.gd:87-104), so a direct resolve mirroring melee is correct. - AttackRequest gains is_ranged (serde-default); process_pvp_combat threads it. - dispatch apply_ranged_attack: owner + enemy + within-range gate, then resolve. - tests: ranged_pvp_no_retaliation (resolver: damage, attacker untouched, 0 retaliation), ranged_attack_no_retaliation (dispatch: range gate + rejections). Deferred (parity, cited): no movement-spend on attack — melee doesn't spend it either; a "ranged is in-scope; verify gate mc-combat+mc-turn+mc-player-api 0 failed. Dispatched combat-dev; verify gate green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b689f52ccc
commit
e8dd4a85b4
11 changed files with 474 additions and 9 deletions
|
|
@ -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<Vec<Event>, 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]) {
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -2796,6 +2796,34 @@ impl TurnProcessor {
|
|||
attacker_unit: usize,
|
||||
defender_player: usize,
|
||||
defender_unit: usize,
|
||||
) -> Option<PvpCombatEvent> {
|
||||
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<PvpCombatEvent> {
|
||||
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<crate::game_state::AttackRequest> =
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ fn fixture_with_posture(posture: CapturePosture) -> GameState {
|
|||
attacker_unit: 0,
|
||||
defender_player: 1,
|
||||
defender_unit: 0,
|
||||
is_ranged: false,
|
||||
});
|
||||
|
||||
state
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
104
src/simulator/crates/mc-turn/tests/ranged_pvp_no_retaliation.rs
Normal file
104
src/simulator/crates/mc-turn/tests/ranged_pvp_no_retaliation.rs
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue