feat(@projects/@magic-civilization): ✨ add brace first-strike combat mechanic tests
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
64ab1726af
commit
55cb2012ac
2 changed files with 240 additions and 0 deletions
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue