feat(@projects/@magic-civilization): ✨ add cleave and pursue combat mechanics
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
40e0312140
commit
8e98b51bfc
7 changed files with 735 additions and 7 deletions
|
|
@ -217,6 +217,18 @@ pub struct CombatParams {
|
|||
/// True when a friendly medic with is_field_aura is co-hexed with the attacker.
|
||||
/// Bridge must scan the attacker's hex before building CombatParams.
|
||||
pub attacker_field_aura_active: bool,
|
||||
// p2-53f/h: Cleave and Pursue
|
||||
/// True when the attacker has the Cleave action available and is using it.
|
||||
/// Resolver emits `cleave_secondary_damage` (50% of primary); bridge applies
|
||||
/// it to the first adjacent enemy the bridge selects.
|
||||
pub attacker_has_cleave: bool,
|
||||
/// True when the attacker is in Pursue posture and this is a melee kill.
|
||||
/// When set AND defender is killed, resolver populates `pursue_advance_to`
|
||||
/// with `defender_hex` so the bridge can advance the attacker.
|
||||
pub attacker_is_pursuing: bool,
|
||||
/// Map position of the defender (offset col, row). Required when
|
||||
/// `attacker_is_pursuing` is true so the resolver can emit `pursue_advance_to`.
|
||||
pub defender_hex: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl Default for CombatParams {
|
||||
|
|
@ -258,6 +270,9 @@ impl Default for CombatParams {
|
|||
attacker_is_ambushing: false,
|
||||
defender_has_stabilise: false,
|
||||
attacker_field_aura_active: false,
|
||||
attacker_has_cleave: false,
|
||||
attacker_is_pursuing: false,
|
||||
defender_hex: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -306,6 +321,16 @@ pub struct CombatResult {
|
|||
/// Caller must set MapUnit::prevented_fatal_this_battle = true and clear
|
||||
/// defender_has_stabilise on the defender's MapUnit.
|
||||
pub stabilise_prevented_kill: bool,
|
||||
// p2-53f: Cleave secondary damage
|
||||
/// Damage to deal to one adjacent secondary target (50% of primary damage).
|
||||
/// 0 when Cleave was not active. Bridge selects the adjacent target and
|
||||
/// applies this damage to it; the resolver does not know map positions.
|
||||
pub cleave_secondary_damage: i32,
|
||||
// p2-53h: Pursue follow-through
|
||||
/// When the attacker is in Pursue posture and the defender was killed in melee,
|
||||
/// this is set to the defender's hex so the bridge can advance the attacker.
|
||||
/// `None` when Pursue did not trigger.
|
||||
pub pursue_advance_to: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
/// The CombatResolver. Stateless — all context is passed in CombatParams.
|
||||
|
|
@ -486,15 +511,36 @@ impl CombatResolver {
|
|||
let has_first_strike = params.attacker_keywords.contains(&Keyword::FirstStrike);
|
||||
let defender_dies_to_first_strike =
|
||||
has_first_strike && damage_to_defender >= params.defender.hp;
|
||||
|
||||
// p2-53f Brace first-strike: when defender is braced and combat is melee,
|
||||
// the defender attacks first. If the attacker is killed by the defender's
|
||||
// first strike, the attacker deals no damage back (mirror of first_strike logic).
|
||||
// Brace first-strike has no effect in ranged combat or when the attacker also
|
||||
// has FirstStrike keyword (attacker keyword wins the ordering dispute).
|
||||
let brace_first_strike_active = !is_ranged
|
||||
&& effective_params.defender_is_braced
|
||||
&& !has_first_strike
|
||||
&& !no_retaliation;
|
||||
let attacker_killed_by_brace =
|
||||
brace_first_strike_active && damage_to_attacker >= params.attacker.hp;
|
||||
|
||||
let final_attacker_damage = if defender_dies_to_first_strike {
|
||||
0
|
||||
} else {
|
||||
damage_to_attacker
|
||||
};
|
||||
|
||||
// When Brace first-strike fires and kills the attacker, the attacker
|
||||
// deals no damage (it's dead before it can strike).
|
||||
let final_damage_to_defender = if attacker_killed_by_brace {
|
||||
0
|
||||
} else {
|
||||
damage_to_defender
|
||||
};
|
||||
|
||||
// Compute remaining HP
|
||||
let attacker_hp = (params.attacker.hp - final_attacker_damage).max(0);
|
||||
let raw_defender_hp = (params.defender.hp - damage_to_defender).max(0);
|
||||
let raw_defender_hp = (params.defender.hp - final_damage_to_defender).max(0);
|
||||
// p2-53i Stabilise: if the blow would be fatal and the defender has
|
||||
// Stabilise active, clamp to 1 HP. Caller sets prevented_fatal_this_battle.
|
||||
let (defender_hp, stabilise_prevented_kill) =
|
||||
|
|
@ -504,11 +550,11 @@ impl CombatResolver {
|
|||
(raw_defender_hp, false)
|
||||
};
|
||||
|
||||
// City damage
|
||||
// City damage (uses final_damage_to_defender so Brace first-strike is reflected)
|
||||
let (city_damage, city_hp_remaining) = if let Some(city_hp) = params.city_hp {
|
||||
if is_ranged {
|
||||
let (city_dmg, _garrison_dmg) = siege::split_ranged_damage_vs_city(
|
||||
damage_to_defender,
|
||||
final_damage_to_defender,
|
||||
params.city_has_garrison,
|
||||
);
|
||||
let siege_mult = if is_siege_vs_city {
|
||||
|
|
@ -520,7 +566,7 @@ impl CombatResolver {
|
|||
((total_city_dmg), (city_hp - total_city_dmg).max(0))
|
||||
} else if params.combat_type == CombatType::Siege {
|
||||
let siege_mult = siege::siege_city_bonus();
|
||||
let city_dmg = (damage_to_defender as f32 * siege_mult).round() as i32;
|
||||
let city_dmg = (final_damage_to_defender as f32 * siege_mult).round() as i32;
|
||||
(city_dmg, (city_hp - city_dmg).max(0))
|
||||
} else {
|
||||
// Melee vs city: only a fraction of unit damage translates to
|
||||
|
|
@ -531,7 +577,7 @@ impl CombatResolver {
|
|||
// regressed checklist results. Seed 1's sub-T100 fall is an
|
||||
// AI production-priority issue, not siege math.
|
||||
let melee_city_fraction: f32 = 0.35;
|
||||
let city_dmg = (damage_to_defender as f32 * melee_city_fraction).round() as i32;
|
||||
let city_dmg = (final_damage_to_defender as f32 * melee_city_fraction).round() as i32;
|
||||
(city_dmg, (city_hp - city_dmg).max(0))
|
||||
}
|
||||
} else {
|
||||
|
|
@ -540,7 +586,7 @@ impl CombatResolver {
|
|||
|
||||
// Life drain
|
||||
let life_drain_heal = if params.attacker_keywords.contains(&Keyword::LifeDrain) {
|
||||
(damage_to_defender as f32 * 0.5).round() as i32
|
||||
(final_damage_to_defender as f32 * 0.5).round() as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
|
@ -564,6 +610,27 @@ impl CombatResolver {
|
|||
0
|
||||
};
|
||||
|
||||
// p2-53f Cleave secondary damage: 50% of the primary hit, applied by the
|
||||
// bridge to one adjacent enemy (first in canonical ODD_Q_NEIGHBORS order).
|
||||
// Only fires when attacker_has_cleave is set and the primary hit landed.
|
||||
let cleave_secondary_damage = if params.attacker_has_cleave && final_damage_to_defender > 0 {
|
||||
(final_damage_to_defender as f32 * 0.50).round() as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// p2-53h Pursue follow-through: when the attacker is in pursue posture and
|
||||
// the defender was killed in melee, emit the defender's hex so the bridge
|
||||
// can advance the attacker into the vacated tile.
|
||||
let pursue_advance_to = if params.attacker_is_pursuing
|
||||
&& !is_ranged
|
||||
&& defender_hp == 0
|
||||
{
|
||||
params.defender_hex
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// XP calculation
|
||||
let strength_ratio = defender_strength / attacker_strength.max(0.01);
|
||||
let attacker_xp = crate::promotions::xp_from_combat(BASE_COMBAT_XP, strength_ratio);
|
||||
|
|
@ -586,7 +653,7 @@ impl CombatResolver {
|
|||
};
|
||||
|
||||
CombatResult {
|
||||
defender_damage: damage_to_defender,
|
||||
defender_damage: final_damage_to_defender,
|
||||
attacker_damage: final_attacker_damage,
|
||||
attacker_outcome,
|
||||
defender_outcome,
|
||||
|
|
@ -600,6 +667,8 @@ impl CombatResolver {
|
|||
new_defender_status_effects,
|
||||
attacker_post_combat_heal,
|
||||
stabilise_prevented_kill,
|
||||
cleave_secondary_damage,
|
||||
pursue_advance_to,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
src/simulator/crates/mc-turn/src/action_handlers/cavalry.rs
Normal file
21
src/simulator/crates/mc-turn/src/action_handlers/cavalry.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//! p2-53h cavalry action handlers.
|
||||
|
||||
use crate::action::{ActionKind, DisabledReason};
|
||||
use crate::game_state::GameState;
|
||||
use super::{ActionError, get_unit_mut};
|
||||
|
||||
pub(super) fn handle_pursue(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Pursue)?;
|
||||
if unit.is_pursuing {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::Pursue,
|
||||
reason: DisabledReason::AlreadyPursuing,
|
||||
});
|
||||
}
|
||||
unit.is_pursuing = true;
|
||||
Ok(())
|
||||
}
|
||||
416
src/simulator/crates/mc-turn/src/action_handlers/infantry.rs
Normal file
416
src/simulator/crates/mc-turn/src/action_handlers/infantry.rs
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
//! p2-53f infantry action handlers — Shield Wall, Brace, Shove, Rage, Cleave, WarCry.
|
||||
|
||||
use mc_core::algorithms::hex::ODD_Q_NEIGHBORS;
|
||||
|
||||
use crate::action::{ActionKind, DisabledReason};
|
||||
use crate::game_state::GameState;
|
||||
use super::{ActionError, get_unit_mut};
|
||||
|
||||
// ── infantry line ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn handle_shield_wall(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::ShieldWall)?;
|
||||
if unit.is_shield_wall {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::ShieldWall,
|
||||
reason: DisabledReason::AlreadyShieldWall,
|
||||
});
|
||||
}
|
||||
unit.is_shield_wall = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_unshield_wall(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::UnshieldWall)?;
|
||||
if !unit.is_shield_wall {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::UnshieldWall,
|
||||
reason: DisabledReason::NotShieldWall,
|
||||
});
|
||||
}
|
||||
unit.is_shield_wall = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_brace(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Brace)?;
|
||||
if unit.is_braced {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::Brace,
|
||||
reason: DisabledReason::AlreadyBraced,
|
||||
});
|
||||
}
|
||||
unit.is_braced = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_unbrace(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Unbrace)?;
|
||||
if !unit.is_braced {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::Unbrace,
|
||||
reason: DisabledReason::NotBraced,
|
||||
});
|
||||
}
|
||||
unit.is_braced = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shove: push the first adjacent enemy unit one hex further away from the acting
|
||||
/// unit, if the destination is empty and passable.
|
||||
///
|
||||
/// The push direction is the ODD_Q_NEIGHBORS direction (0-5) from the acting
|
||||
/// unit to the target enemy. The destination is one additional step in that
|
||||
/// same direction. Iteration order is canonical (direction 0–5); the first
|
||||
/// eligible adjacent enemy is pushed.
|
||||
///
|
||||
/// Failure cases:
|
||||
/// - No adjacent enemy found → `NoAdjacentTarget`
|
||||
/// - Destination is occupied or impassable → `ShoveBlocked`
|
||||
pub(super) fn handle_shove(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
// Read acting unit position immutably first.
|
||||
let (actor_col, actor_row) = {
|
||||
let unit = state
|
||||
.players
|
||||
.get(player_idx)
|
||||
.and_then(|p| p.units.get(unit_idx))
|
||||
.ok_or(ActionError { kind: ActionKind::Shove, reason: DisabledReason::WrongTerrain })?;
|
||||
(unit.col, unit.row)
|
||||
};
|
||||
|
||||
let parity = (actor_col & 1) as usize;
|
||||
|
||||
// Find the first adjacent enemy and the push direction.
|
||||
// Enemy = any unit belonging to a different player, in an adjacent hex.
|
||||
let mut shove_target: Option<(usize, usize, i32, i32, usize)> = None; // (pi, ui, dest_col, dest_row, dir)
|
||||
|
||||
'outer: for dir in 0..6usize {
|
||||
let (dc, dr) = ODD_Q_NEIGHBORS[parity][dir];
|
||||
let neighbor_col = actor_col + dc;
|
||||
let neighbor_row = actor_row + dr;
|
||||
|
||||
// Check each player other than the acting player for a unit on this hex.
|
||||
for (enemy_pi, enemy_player) in state.players.iter().enumerate() {
|
||||
if enemy_pi == player_idx {
|
||||
continue;
|
||||
}
|
||||
for (enemy_ui, enemy_unit) in enemy_player.units.iter().enumerate() {
|
||||
if enemy_unit.col == neighbor_col && enemy_unit.row == neighbor_row {
|
||||
// Found an adjacent enemy. Compute push destination.
|
||||
// The parity of the neighbor hex determines the next step.
|
||||
let dest_parity = (neighbor_col & 1) as usize;
|
||||
let (ddc, ddr) = ODD_Q_NEIGHBORS[dest_parity][dir];
|
||||
let dest_col = neighbor_col + ddc;
|
||||
let dest_row = neighbor_row + ddr;
|
||||
shove_target = Some((enemy_pi, enemy_ui, dest_col, dest_row, dir));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (enemy_pi, enemy_ui, dest_col, dest_row, _dir) =
|
||||
shove_target.ok_or(ActionError { kind: ActionKind::Shove, reason: DisabledReason::NoAdjacentTarget })?;
|
||||
|
||||
// Check destination is passable (not mountain/ocean) if grid is available.
|
||||
if let Some(ref grid) = state.grid {
|
||||
match grid.tile(dest_col, dest_row) {
|
||||
None => {
|
||||
// Out of bounds — blocked.
|
||||
return Err(ActionError { kind: ActionKind::Shove, reason: DisabledReason::ShoveBlocked });
|
||||
}
|
||||
Some(tile) => {
|
||||
if is_impassable_biome(&tile.biome_label_id) {
|
||||
return Err(ActionError { kind: ActionKind::Shove, reason: DisabledReason::ShoveBlocked });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No grid: check bounds only by ensuring dest coords are non-negative.
|
||||
// (In tests without a grid, we skip the biome check.)
|
||||
if dest_col < 0 || dest_row < 0 {
|
||||
return Err(ActionError { kind: ActionKind::Shove, reason: DisabledReason::ShoveBlocked });
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the destination hex is not occupied by any unit.
|
||||
let dest_occupied = state.players.iter().any(|p| {
|
||||
p.units.iter().any(|u| u.col == dest_col && u.row == dest_row)
|
||||
});
|
||||
if dest_occupied {
|
||||
return Err(ActionError { kind: ActionKind::Shove, reason: DisabledReason::ShoveBlocked });
|
||||
}
|
||||
|
||||
// Apply the shove: move enemy unit and break its formation.
|
||||
let enemy_unit = &mut state.players[enemy_pi].units[enemy_ui];
|
||||
enemy_unit.col = dest_col;
|
||||
enemy_unit.row = dest_row;
|
||||
enemy_unit.formation_id = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true when the given biome name is impassable for land movement.
|
||||
fn is_impassable_biome(biome: &str) -> bool {
|
||||
matches!(
|
||||
biome,
|
||||
"mountain" | "high_mountain" | "volcanic_mountain"
|
||||
| "deep_ocean" | "shallow_ocean" | "ocean"
|
||||
)
|
||||
}
|
||||
|
||||
// ── infantry shock ────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn handle_rage(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Rage)?;
|
||||
if unit.rage_turns_remaining > 0 {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::Rage,
|
||||
reason: DisabledReason::AlreadyRaging,
|
||||
});
|
||||
}
|
||||
// +40% attack for 2 turns; Fortify and Sentry are incompatible (cleared here).
|
||||
unit.rage_turns_remaining = 2;
|
||||
unit.is_fortified = false;
|
||||
unit.is_sentrying = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// WarCry: set `war_cry_used_this_battle`, then scan all 6 adjacent hexes for
|
||||
/// enemy units. Any enemy found within 1 hex receives `war_cry_debuff_turns_remaining = 1`
|
||||
/// (–10% attack for 1 turn, ticked down by the turn processor each turn).
|
||||
pub(super) fn handle_war_cry(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
// Gate: once per battle.
|
||||
{
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::WarCry)?;
|
||||
if unit.war_cry_used_this_battle {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::WarCry,
|
||||
reason: DisabledReason::WarCryUsed,
|
||||
});
|
||||
}
|
||||
unit.war_cry_used_this_battle = true;
|
||||
}
|
||||
|
||||
// Determine the acting unit's position for adjacency scan.
|
||||
let (actor_col, actor_row) = {
|
||||
let unit = &state.players[player_idx].units[unit_idx];
|
||||
(unit.col, unit.row)
|
||||
};
|
||||
|
||||
let parity = (actor_col & 1) as usize;
|
||||
|
||||
// Collect (player_idx, unit_idx) pairs for adjacent enemy units.
|
||||
// We need to do this in a separate pass to avoid borrow conflicts.
|
||||
let mut targets: Vec<(usize, usize)> = Vec::new();
|
||||
for dir in 0..6usize {
|
||||
let (dc, dr) = ODD_Q_NEIGHBORS[parity][dir];
|
||||
let neighbor_col = actor_col + dc;
|
||||
let neighbor_row = actor_row + dr;
|
||||
|
||||
for (pi, player) in state.players.iter().enumerate() {
|
||||
if pi == player_idx {
|
||||
continue;
|
||||
}
|
||||
for (ui, unit) in player.units.iter().enumerate() {
|
||||
if unit.col == neighbor_col && unit.row == neighbor_row {
|
||||
targets.push((pi, ui));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply debuff to each adjacent enemy.
|
||||
for (pi, ui) in targets {
|
||||
state.players[pi].units[ui].war_cry_debuff_turns_remaining = 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_state::{GameState, MapUnit, PlayerState};
|
||||
|
||||
fn minimal_unit_at(col: i32, row: i32) -> MapUnit {
|
||||
MapUnit {
|
||||
col,
|
||||
row,
|
||||
hp: 50,
|
||||
max_hp: 50,
|
||||
attack: 10,
|
||||
defense: 5,
|
||||
unit_id: "dwarf_warrior".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn two_player_state(actor: MapUnit, enemy: MapUnit) -> (GameState, usize, usize) {
|
||||
let player0 = PlayerState {
|
||||
player_index: 0,
|
||||
units: vec![actor],
|
||||
..Default::default()
|
||||
};
|
||||
let player1 = PlayerState {
|
||||
player_index: 1,
|
||||
units: vec![enemy],
|
||||
..Default::default()
|
||||
};
|
||||
let state = GameState {
|
||||
turn: 0,
|
||||
players: vec![player0, player1],
|
||||
grid: None,
|
||||
..Default::default()
|
||||
};
|
||||
(state, 0, 0)
|
||||
}
|
||||
|
||||
// ── Shove tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn shove_pushes_enemy_to_empty_hex() {
|
||||
// Actor at (0, 0), enemy at (1, 0) — ODD_Q_NEIGHBORS[0][0] = (1, 0) direction E.
|
||||
// Push destination from (1,0) in direction E with parity=(1&1)=1:
|
||||
// ODD_Q_NEIGHBORS[1][0] = (1, 0) → dest = (2, 0).
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let enemy = minimal_unit_at(1, 0);
|
||||
let (mut state, pi, ui) = two_player_state(actor, enemy);
|
||||
|
||||
handle_shove(&mut state, pi, ui).unwrap();
|
||||
|
||||
let moved = &state.players[1].units[0];
|
||||
assert_eq!(moved.col, 2, "enemy should be pushed to col 2");
|
||||
assert_eq!(moved.row, 0, "enemy row should not change");
|
||||
assert!(moved.formation_id.is_none(), "shove breaks formation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shove_breaks_formation() {
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let mut enemy = minimal_unit_at(1, 0);
|
||||
enemy.formation_id = Some(42);
|
||||
let (mut state, pi, ui) = two_player_state(actor, enemy);
|
||||
|
||||
handle_shove(&mut state, pi, ui).unwrap();
|
||||
|
||||
assert!(
|
||||
state.players[1].units[0].formation_id.is_none(),
|
||||
"shove must clear formation_id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shove_no_adjacent_enemy_returns_no_adjacent_target() {
|
||||
// Enemy is 2 hexes away — not adjacent.
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let enemy = minimal_unit_at(3, 0);
|
||||
let (mut state, pi, ui) = two_player_state(actor, enemy);
|
||||
|
||||
let err = handle_shove(&mut state, pi, ui).unwrap_err();
|
||||
assert_eq!(err.reason, DisabledReason::NoAdjacentTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shove_blocked_when_destination_occupied() {
|
||||
// Actor at (0,0), enemy at (1,0), blocker at (2,0).
|
||||
// Push dest would be (2,0) — already occupied.
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let enemy = minimal_unit_at(1, 0);
|
||||
let blocker = minimal_unit_at(2, 0);
|
||||
let player0 = PlayerState {
|
||||
player_index: 0,
|
||||
units: vec![actor],
|
||||
..Default::default()
|
||||
};
|
||||
let player1 = PlayerState {
|
||||
player_index: 1,
|
||||
units: vec![enemy, blocker],
|
||||
..Default::default()
|
||||
};
|
||||
let mut state = GameState {
|
||||
turn: 0,
|
||||
players: vec![player0, player1],
|
||||
grid: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = handle_shove(&mut state, 0, 0).unwrap_err();
|
||||
assert_eq!(err.reason, DisabledReason::ShoveBlocked);
|
||||
}
|
||||
|
||||
// ── WarCry adjacency-scan tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn war_cry_debuff_applied_to_adjacent_enemies() {
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let enemy = minimal_unit_at(1, 0); // adjacent
|
||||
let (mut state, pi, ui) = two_player_state(actor, enemy);
|
||||
|
||||
handle_war_cry(&mut state, pi, ui).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.players[1].units[0].war_cry_debuff_turns_remaining,
|
||||
1,
|
||||
"adjacent enemy must receive war_cry_debuff_turns_remaining = 1"
|
||||
);
|
||||
assert!(
|
||||
state.players[0].units[0].war_cry_used_this_battle,
|
||||
"war_cry_used_this_battle must be set on caster"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn war_cry_does_not_debuff_non_adjacent_enemies() {
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let enemy = minimal_unit_at(3, 0); // not adjacent
|
||||
let (mut state, pi, ui) = two_player_state(actor, enemy);
|
||||
|
||||
handle_war_cry(&mut state, pi, ui).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.players[1].units[0].war_cry_debuff_turns_remaining,
|
||||
0,
|
||||
"non-adjacent enemy must NOT receive debuff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn war_cry_twice_in_battle_errors() {
|
||||
let actor = minimal_unit_at(0, 0);
|
||||
let enemy = minimal_unit_at(1, 0);
|
||||
let (mut state, pi, ui) = two_player_state(actor, enemy);
|
||||
|
||||
handle_war_cry(&mut state, pi, ui).unwrap();
|
||||
let err = handle_war_cry(&mut state, pi, ui).unwrap_err();
|
||||
assert_eq!(err.reason, DisabledReason::WarCryUsed);
|
||||
}
|
||||
}
|
||||
53
src/simulator/crates/mc-turn/src/action_handlers/ranged.rs
Normal file
53
src/simulator/crates/mc-turn/src/action_handlers/ranged.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! p2-53g ranged action handlers.
|
||||
|
||||
use crate::action::{ActionKind, DisabledReason};
|
||||
use crate::game_state::GameState;
|
||||
use super::{ActionError, get_unit_mut};
|
||||
|
||||
pub(super) fn handle_aimed_shot(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::AimedShot)?;
|
||||
if unit.aimed_shot_pending {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::AimedShot,
|
||||
reason: DisabledReason::AlreadyAiming,
|
||||
});
|
||||
}
|
||||
unit.aimed_shot_pending = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_fire_arrows(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::FireArrows)?;
|
||||
if unit.is_fire_arrows {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::FireArrows,
|
||||
reason: DisabledReason::AlreadyFireArrows,
|
||||
});
|
||||
}
|
||||
unit.is_fire_arrows = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_stop_fire_arrows(
|
||||
state: &mut GameState,
|
||||
player_idx: usize,
|
||||
unit_idx: usize,
|
||||
) -> Result<(), ActionError> {
|
||||
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::StopFireArrows)?;
|
||||
if !unit.is_fire_arrows {
|
||||
return Err(ActionError {
|
||||
kind: ActionKind::StopFireArrows,
|
||||
reason: DisabledReason::NotFireArrows,
|
||||
});
|
||||
}
|
||||
unit.is_fire_arrows = false;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -443,4 +443,164 @@ mod tests {
|
|||
};
|
||||
assert_eq!(invoke(&mut state, &req), Err(BuildingActionError::PlayerOutOfRange));
|
||||
}
|
||||
|
||||
fn state_with_player() -> GameState {
|
||||
let mut state = GameState::default();
|
||||
state.players.push(PlayerState::default());
|
||||
state
|
||||
}
|
||||
|
||||
fn req(kind: BuildingActionKind) -> BuildingActionRequest {
|
||||
BuildingActionRequest {
|
||||
player_idx: 0, city_idx: 0,
|
||||
building_id: "test_building".to_string(),
|
||||
kind, unit_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn req_with_unit(kind: BuildingActionKind, unit_id: u32) -> BuildingActionRequest {
|
||||
BuildingActionRequest {
|
||||
player_idx: 0, city_idx: 0,
|
||||
building_id: "test_building".to_string(),
|
||||
kind, unit_id: Some(unit_id),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_active_flips_is_active() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::ToggleActive)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(!bs.is_active, "ToggleActive should deactivate active building");
|
||||
|
||||
invoke(&mut state, &req(BuildingActionKind::ToggleActive)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.is_active, "ToggleActive again should reactivate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garrison_in_without_unit_id_returns_unit_not_found() {
|
||||
let mut state = state_with_player();
|
||||
assert_eq!(
|
||||
invoke(&mut state, &req(BuildingActionKind::GarrisonIn)),
|
||||
Err(BuildingActionError::UnitNotFound)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garrison_in_with_unit_not_in_state_returns_unit_not_found() {
|
||||
let mut state = state_with_player();
|
||||
// Unit ID 99 doesn't exist in player's units
|
||||
assert_eq!(
|
||||
invoke(&mut state, &req_with_unit(BuildingActionKind::GarrisonIn, 99)),
|
||||
Err(BuildingActionError::UnitNotFound)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garrison_in_and_out_round_trip() {
|
||||
use crate::game_state::MapUnit;
|
||||
let mut state = state_with_player();
|
||||
let unit = MapUnit { id: 5, col: 0, row: 0, hp: 10, max_hp: 10, ..Default::default() };
|
||||
state.players[0].units.push(unit);
|
||||
|
||||
invoke(&mut state, &req_with_unit(BuildingActionKind::GarrisonIn, 5)).unwrap();
|
||||
{
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.garrisoned_unit_ids.contains(&5), "unit should be garrisoned");
|
||||
}
|
||||
|
||||
invoke(&mut state, &req_with_unit(BuildingActionKind::GarrisonOut, 5)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(!bs.garrisoned_unit_ids.contains(&5), "unit should be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn murder_holes_toggles() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::MurderHoles)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.murder_holes_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_toggles() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::Gate)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.gate_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overdrive_starts_continuous_action() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::Overdrive)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.current_continuous.is_some(), "Overdrive should start a continuous action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raze_starts_continuous_action() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::Raze)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.current_continuous.is_some(), "Raze should start a continuous action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_action_blocked_when_in_progress() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::Raze)).unwrap();
|
||||
let result = invoke(&mut state, &req(BuildingActionKind::Overdrive));
|
||||
assert_eq!(result, Err(BuildingActionError::ContinuousActionInProgress));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_building_action_ticks_to_completion() {
|
||||
let mut state = state_with_player();
|
||||
invoke(&mut state, &req(BuildingActionKind::Overdrive)).unwrap();
|
||||
{
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
let turns = bs.current_continuous.as_ref().unwrap().turns_remaining();
|
||||
assert_eq!(turns, 3);
|
||||
}
|
||||
tick_continuous_building_actions(&mut state);
|
||||
tick_continuous_building_actions(&mut state);
|
||||
tick_continuous_building_actions(&mut state);
|
||||
{
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
let turns = bs.current_continuous.as_ref().unwrap().turns_remaining();
|
||||
assert_eq!(turns, 0);
|
||||
}
|
||||
tick_continuous_building_actions(&mut state);
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert!(bs.current_continuous.is_none(), "action should complete after 4 ticks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_alarm_sets_expiry_turn() {
|
||||
let mut state = state_with_player();
|
||||
state.turn = 10;
|
||||
invoke(&mut state, &req(BuildingActionKind::SoundAlarm)).unwrap();
|
||||
let bs = state.players[0].building_states
|
||||
.get(&(0, "test_building".to_string())).unwrap();
|
||||
assert_eq!(bs.sound_alarm_expiry, 13, "expiry should be turn + 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_always_succeeds() {
|
||||
let mut state = state_with_player();
|
||||
assert!(invoke(&mut state, &req(BuildingActionKind::Manage)).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -578,6 +578,11 @@ pub struct MapUnit {
|
|||
/// True when WarCry has been used this battle (once-per-battle gate).
|
||||
#[serde(default)]
|
||||
pub war_cry_used_this_battle: bool,
|
||||
/// Remaining turns of WarCry attack debuff on this unit (-10% attack).
|
||||
/// Set to 1 when an adjacent enemy uses WarCry; ticked down each turn.
|
||||
/// Bridge reads `> 0` to set `CombatParams::attacker_war_cry_debuff`.
|
||||
#[serde(default)]
|
||||
pub war_cry_debuff_turns_remaining: u8,
|
||||
/// Pending drill XP from Barracks Drill action; consumed by bridge this turn.
|
||||
#[serde(default, skip_serializing_if = "crate::game_state::is_zero_u32")]
|
||||
pub pending_drill_xp: u32,
|
||||
|
|
|
|||
|
|
@ -315,6 +315,10 @@ impl TurnProcessor {
|
|||
if unit.rage_turns_remaining > 0 {
|
||||
unit.rage_turns_remaining -= 1;
|
||||
}
|
||||
// p2-53f: WarCry debuff lasts 1 turn on afflicted enemy units.
|
||||
if unit.war_cry_debuff_turns_remaining > 0 {
|
||||
unit.war_cry_debuff_turns_remaining -= 1;
|
||||
}
|
||||
// Tick status effects: apply damage-per-turn and remove expired.
|
||||
let mut alive_effects = Vec::new();
|
||||
for effect in unit.status_effects.drain(..) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue