feat(@projects/@magic-civilization): add brace first-strike combat mechanic tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-02 19:49:44 -04:00
parent 64ab1726af
commit 55cb2012ac
2 changed files with 240 additions and 0 deletions

View file

@ -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(&params);
// 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(&params);
// 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(&params);
// 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(&params);
// 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(&params);
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(&params);
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(&params);
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");
}
}

View file

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