From 8e98b51bfcd379a28e6885d70d1543dd38684d10 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 2 May 2026 19:32:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20cleave=20and=20pursue=20combat=20mechanics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-combat/src/resolver.rs | 83 +++- .../mc-turn/src/action_handlers/cavalry.rs | 21 + .../mc-turn/src/action_handlers/infantry.rs | 416 ++++++++++++++++++ .../mc-turn/src/action_handlers/ranged.rs | 53 +++ .../mc-turn/src/building_action_handlers.rs | 160 +++++++ .../crates/mc-turn/src/game_state.rs | 5 + src/simulator/crates/mc-turn/src/processor.rs | 4 + 7 files changed, 735 insertions(+), 7 deletions(-) create mode 100644 src/simulator/crates/mc-turn/src/action_handlers/cavalry.rs create mode 100644 src/simulator/crates/mc-turn/src/action_handlers/infantry.rs create mode 100644 src/simulator/crates/mc-turn/src/action_handlers/ranged.rs diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs index a9280655..3f56b202 100644 --- a/src/simulator/crates/mc-combat/src/resolver.rs +++ b/src/simulator/crates/mc-combat/src/resolver.rs @@ -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, } } diff --git a/src/simulator/crates/mc-turn/src/action_handlers/cavalry.rs b/src/simulator/crates/mc-turn/src/action_handlers/cavalry.rs new file mode 100644 index 00000000..c0f4379e --- /dev/null +++ b/src/simulator/crates/mc-turn/src/action_handlers/cavalry.rs @@ -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(()) +} diff --git a/src/simulator/crates/mc-turn/src/action_handlers/infantry.rs b/src/simulator/crates/mc-turn/src/action_handlers/infantry.rs new file mode 100644 index 00000000..19c42432 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/action_handlers/infantry.rs @@ -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); + } +} diff --git a/src/simulator/crates/mc-turn/src/action_handlers/ranged.rs b/src/simulator/crates/mc-turn/src/action_handlers/ranged.rs new file mode 100644 index 00000000..2d1d3cfb --- /dev/null +++ b/src/simulator/crates/mc-turn/src/action_handlers/ranged.rs @@ -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(()) +} diff --git a/src/simulator/crates/mc-turn/src/building_action_handlers.rs b/src/simulator/crates/mc-turn/src/building_action_handlers.rs index 5b1704b7..4a9b82c9 100644 --- a/src/simulator/crates/mc-turn/src/building_action_handlers.rs +++ b/src/simulator/crates/mc-turn/src/building_action_handlers.rs @@ -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()); + } } diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 515bb7cb..8c2272bd 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -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, diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 4e1f6279..9bd0d34d 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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(..) {