diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs index 3f56b202..a04e11d7 100644 --- a/src/simulator/crates/mc-combat/src/resolver.rs +++ b/src/simulator/crates/mc-combat/src/resolver.rs @@ -1383,4 +1383,167 @@ mod tests { assert_eq!(result.attacker_post_combat_heal, 0); } + // ── p2-53f Brace first-strike ───────────────────────────────────────────── + + #[test] + fn brace_first_strikes_attacker() { + // Braced defender: defender attacks first. If it kills the attacker, + // the attacker deals 0 damage back. + let strong_defender = UnitStats { + hp: 100, max_hp: 100, + attack: 100, defense: 5, + ranged_attack: 0, range: 0, movement: 2, + }; + let weak_attacker = UnitStats { + hp: 10, max_hp: 60, + attack: 5, defense: 1, + ranged_attack: 0, range: 0, movement: 2, + }; + let params = CombatParams { + attacker: weak_attacker, + defender: strong_defender, + combat_type: CombatType::Melee, + defender_is_braced: true, + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + // Attacker should be killed by brace first-strike + assert_eq!(result.attacker_outcome, CombatOutcome::Killed, + "brace first-strike should kill weak attacker"); + // Attacker dealt 0 damage (dead before its strike) + assert_eq!(result.defender_damage, 0, + "killed-by-brace attacker should deal 0 damage to defender"); + } + + #[test] + fn brace_first_strike_no_effect_if_attacker_survives() { + // Equal units: brace fires but attacker survives, so attacker still deals damage. + let params = CombatParams { + defender_is_braced: true, + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + // Attacker took some damage from brace first-strike (normal retaliation) + assert!(result.attacker_damage > 0, + "surviving attacker still takes retaliation from brace"); + // Defender also took damage (attacker survived so it struck) + assert!(result.defender_damage > 0, + "surviving attacker should deal damage even against braced defender"); + } + + #[test] + fn brace_first_strike_does_not_apply_to_ranged() { + // Brace only grants first-strike in melee, not vs ranged. + let crossbow = crossbow_stats(); + let braced_defender = warrior_stats(); + let params = CombatParams { + attacker: crossbow, + defender: braced_defender, + combat_type: CombatType::Ranged, + defender_is_braced: true, + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + // Ranged: no retaliation regardless of brace + assert_eq!(result.attacker_damage, 0, "ranged takes no retaliation"); + assert!(result.defender_damage > 0, "ranged still deals damage"); + } + + // ── p2-53f Cleave secondary damage ──────────────────────────────────────── + + #[test] + fn cleave_hits_one_adjacent_enemy_at_50_pct() { + let params = CombatParams { + attacker_has_cleave: true, + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + // Primary damage should be positive + assert!(result.defender_damage > 0, "cleave primary hit must deal damage"); + // Secondary damage = 50% of primary (rounded) + let expected_secondary = (result.defender_damage as f32 * 0.50).round() as i32; + assert_eq!(result.cleave_secondary_damage, expected_secondary, + "cleave secondary must be 50% of primary damage"); + } + + #[test] + fn cleave_secondary_zero_without_cleave() { + let result = CombatResolver::resolve(&CombatParams::default()); + assert_eq!(result.cleave_secondary_damage, 0, + "no cleave flag → cleave_secondary_damage must be 0"); + } + + // ── p2-53h Pursue follow-through ───────────────────────────────────────── + + #[test] + fn pursue_advance_to_set_on_kill() { + // Strong attacker with pursue flag kills defender → advance_to is populated. + let strong = UnitStats { + hp: 100, max_hp: 100, + attack: 100, defense: 5, + ranged_attack: 0, range: 0, movement: 2, + }; + let weak_defender = UnitStats { + hp: 10, max_hp: 60, + attack: 2, defense: 1, + ranged_attack: 0, range: 0, movement: 2, + }; + let params = CombatParams { + attacker: strong, + defender: weak_defender, + combat_type: CombatType::Melee, + attacker_is_pursuing: true, + defender_hex: Some((5, 3)), + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + assert_eq!(result.defender_outcome, CombatOutcome::Killed, + "defender must be killed for pursue to trigger"); + assert_eq!(result.pursue_advance_to, Some((5, 3)), + "pursue_advance_to must be set to defender's hex on kill"); + } + + #[test] + fn pursue_advance_to_none_when_defender_survives() { + // Equal units: defender survives, no pursue advance. + let params = CombatParams { + attacker_is_pursuing: true, + defender_hex: Some((5, 3)), + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + assert_eq!(result.defender_outcome, CombatOutcome::Survived, + "equal units: defender must survive"); + assert!(result.pursue_advance_to.is_none(), + "pursue_advance_to must be None when defender survives"); + } + + #[test] + fn pursue_advance_to_none_for_ranged() { + // Pursue is melee-only; ranged kills do not trigger advance. + let strong_ranged = UnitStats { + hp: 100, max_hp: 100, + attack: 0, defense: 1, + ranged_attack: 200, range: 2, movement: 2, + }; + let weak_defender = UnitStats { + hp: 5, max_hp: 60, + attack: 2, defense: 1, + ranged_attack: 0, range: 0, movement: 2, + }; + let params = CombatParams { + attacker: strong_ranged, + defender: weak_defender, + combat_type: CombatType::Ranged, + attacker_is_pursuing: true, + defender_hex: Some((5, 3)), + ..CombatParams::default() + }; + let result = CombatResolver::resolve(¶ms); + assert_eq!(result.defender_outcome, CombatOutcome::Killed, + "strong ranged must kill weak defender"); + assert!(result.pursue_advance_to.is_none(), + "pursue_advance_to must be None for ranged kills"); + } + } \ No newline at end of file diff --git a/src/simulator/crates/mc-core/src/building_action.rs b/src/simulator/crates/mc-core/src/building_action.rs index 8fe77f64..18c8d737 100644 --- a/src/simulator/crates/mc-core/src/building_action.rs +++ b/src/simulator/crates/mc-core/src/building_action.rs @@ -558,9 +558,86 @@ mod tests { BuildingActionKind::Repair, BuildingActionKind::ToggleActive, BuildingActionKind::Manage, + BuildingActionKind::Drill, + BuildingActionKind::AutoPromote, + BuildingActionKind::RangedFire, + BuildingActionKind::SoundAlarm, + BuildingActionKind::FireArrows, + BuildingActionKind::RepairSegment, + BuildingActionKind::MurderHoles, + BuildingActionKind::Gate, + BuildingActionKind::Raze, + BuildingActionKind::Annex, + BuildingActionKind::Stockpile, + BuildingActionKind::Overdrive, + BuildingActionKind::ResearchAid, + BuildingActionKind::InvokeAncestor, + BuildingActionKind::InscribeHero, + BuildingActionKind::PackAndMarch, + BuildingActionKind::SupplyAura, + BuildingActionKind::ClaimTerritory, + BuildingActionKind::LightBeacon, ] { assert_eq!(BuildingActionKind::from_str(kind.as_str()), Some(kind), "round-trip failed for {:?}", kind); } } + + #[test] + fn barracks_building_has_drill_and_auto_promote() { + let cap = BuildingCapability { + building_type: "production".into(), + keywords: vec!["barracks".into()], + ..base_cap_defaults() + }; + let actions = legal_actions_for_building(&cap); + let kinds: Vec = actions.iter().map(|a| a.kind).collect(); + assert!(kinds.contains(&BuildingActionKind::Drill)); + assert!(kinds.contains(&BuildingActionKind::AutoPromote)); + assert!(kinds.contains(&BuildingActionKind::Manage)); + } + + #[test] + fn wall_building_has_wall_specific_actions() { + let cap = BuildingCapability { + building_type: "defensive".into(), + keywords: vec!["wall".into(), "repairable".into()], + current_hp: 50, + max_hp: 100, + ..base_cap_defaults() + }; + let actions = legal_actions_for_building(&cap); + let kinds: Vec = actions.iter().map(|a| a.kind).collect(); + assert!(kinds.contains(&BuildingActionKind::RepairSegment)); + assert!(kinds.contains(&BuildingActionKind::MurderHoles)); + assert!(kinds.contains(&BuildingActionKind::Gate)); + } + + #[test] + fn overdrive_disabled_when_continuous_in_progress() { + let cap = BuildingCapability { + building_type: "production".into(), + keywords: vec!["workshop".into()], + continuous_action_in_progress: true, + ..base_cap_defaults() + }; + let actions = legal_actions_for_building(&cap); + let overdrive = actions.iter().find(|a| a.kind == BuildingActionKind::Overdrive).unwrap(); + assert!(!overdrive.enabled); + assert_eq!(overdrive.disabled_reason, Some(BuildingDisabledReason::ContinuousActionInProgress)); + } + + #[test] + fn ancestor_hall_invoke_disabled_after_use() { + let cap = BuildingCapability { + building_type: "wonder".into(), + keywords: vec!["ancestor_hall".into()], + ancestor_invoked_this_era: true, + ..base_cap_defaults() + }; + let actions = legal_actions_for_building(&cap); + let invoke_a = actions.iter().find(|a| a.kind == BuildingActionKind::InvokeAncestor).unwrap(); + assert!(!invoke_a.enabled); + assert_eq!(invoke_a.disabled_reason, Some(BuildingDisabledReason::AlreadyUsedThisEra)); + } }