feat(@projects/@magic-civilization): add cleave and pursue combat mechanics

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-02 19:32:35 -04:00
parent 40e0312140
commit 8e98b51bfc
7 changed files with 735 additions and 7 deletions

View file

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

View 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(())
}

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

View 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(())
}

View file

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

View file

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

View file

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