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:
Natalie 2026-06-27 09:21:36 -04:00
parent b689f52ccc
commit e8dd4a85b4
11 changed files with 474 additions and 9 deletions

View file

@ -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]) {

View file

@ -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:?}"
);
}

View file

@ -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).

View file

@ -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();

View file

@ -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
}

View file

@ -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
}

View file

@ -125,6 +125,7 @@ fn fixture_with_posture(posture: CapturePosture) -> GameState {
attacker_unit: 0,
defender_player: 1,
defender_unit: 0,
is_ranged: false,
});
state

View file

@ -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

View file

@ -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);

View file

@ -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);

View 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"
);
}