using Outnumbered.Domain; using Xunit; namespace Outnumbered.Tests; // CombatResolver's offense/defense multiplier chains — the single source of truth shared by the damage hook + the HUD. // Anchors are hand-traced from the documented chain (dmgMul*hsMul*critMul*abilityMul*mDeal etc.) with mDeal/mTake passed // explicitly so the handicap band is isolated from the stat/ability composition. Ability defaults: Overcharge.Magnitude=50, // Adrenaline.Magnitude=35, Berserk.Magnitude=1.5/Magnitude2=1.5. crit_damage Base=100 (so a no-investment crit is x2). public class CombatChainTests { private static readonly Dictionary MaxedOffense = new(StringComparer.Ordinal) { [StatKeys.Damage] = 5, [StatKeys.HeadshotDamage] = 5, [StatKeys.CritDamage] = 10 }; // ---- OffenseMultiplier (precomputed-mDeal overload) ---- [Fact] public void Offense_maxed_headshot_crit() { // dmg +50% (1.5) * hs +50% (1.5) * crit +200% (3.0) * 1 * mDeal(1) = 6.75 var s = T.Snap(upgrades: MaxedOffense); T.Close(6.75, CombatResolver.OffenseMultiplier(s, headshot: true, crit: true, T.StatDefs(), T.Abil(), T.BaseMaxHp, mDeal: 1.0)); } [Fact] public void Offense_maxed_bodyshot_no_crit_only_damage_applies() { var s = T.Snap(upgrades: MaxedOffense); T.Close(1.5, CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, T.StatDefs(), T.Abil(), T.BaseMaxHp, mDeal: 1.0)); } [Fact] public void Offense_overcharge_multiplies_by_magnitude() { // no stats, Overcharge active -> abilityMul 1.5 var s = T.Snap(overcharge: true); T.Close(1.5, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0)); } [Fact] public void Offense_berserk_passive_card_scales_with_missing_hp() { // berserk_passive card 120, Health 50/maxHp 100 -> missing 0.5 -> abilityMul 1 + 0.5*120/100 = 1.6 var s = T.Snap(health: 50, cards: new Cards((CardKeys.BerserkPassive, 120))); T.Close(1.6, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0)); } [Fact] public void Offense_berserk_ability_scales_with_missing_hp() { // Berserk ability active, Health 25 -> missing 0.75; abilityMul 1 + 0.75*1.5 = 2.125 var s = T.Snap(health: 25, berserk: true); T.Close(2.125, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0)); } [Fact] public void Offense_berserk_ability_crit_adds_to_crit_damage() { // crit (no crit_damage stat -> base x2 = 2.0) + Berserk crit bonus missing*Magnitude2 = 0.75*1.5 -> critMul 3.125; // abilityMul 2.125 -> 3.125 * 2.125 = 6.640625 var s = T.Snap(health: 25, berserk: true); T.Close(6.640625, CombatResolver.OffenseMultiplier(s, false, crit: true, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0)); } [Fact] public void Offense_abilities_disabled_ignores_active_flags() { var ab = T.Abil(); ab.Enabled = false; var s = T.Snap(overcharge: true, berserk: true, health: 25); // flags set but abilities off T.Close(1.0, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), ab, T.BaseMaxHp, 1.0)); } [Fact] public void Offense_passes_mDeal_through_linearly() { var s = T.Snap(upgrades: MaxedOffense); // 6.75 * 0.1 = 0.675 (a dominant player's compressed deal band) T.Close(0.675, CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), T.Abil(), T.BaseMaxHp, 0.1)); } // ---- DefenseMultiplier (precomputed-mTake overload) ---- [Fact] public void Defense_plain_is_just_mTake() => T.Close(1.0, CombatResolver.DefenseMultiplier(T.Snap(), headshot: false, T.Abil(), mTake: 1.0)); [Fact] public void Defense_adrenaline_reduces_take() { var s = T.Snap(adrenaline: true); T.Close(0.65, CombatResolver.DefenseMultiplier(s, false, T.Abil(), 1.0)); // *(1 - 35/100) } [Fact] public void Defense_headshot_reduction_card_only_on_headshots() { var s = T.Snap(cards: new Cards((CardKeys.HsReduction, 45))); T.Close(1.0, CombatResolver.DefenseMultiplier(s, headshot: false, T.Abil(), 1.0)); // body: card dormant T.Close(0.55, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), 1.0)); // *(1 - 45/100) } [Fact] public void Defense_headshot_reduction_over_hundred_clamps_to_zero() { var s = T.Snap(cards: new Cards((CardKeys.HsReduction, 120))); T.Close(0.0, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), 1.0)); // max(0, 1-1.2) } [Fact] public void Defense_stacks_adrenaline_and_hs_card_on_mTake() { var s = T.Snap(adrenaline: true, cards: new Cards((CardKeys.HsReduction, 45))); T.Close(0.715, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), mTake: 2.0)); // 2 * 0.65 * 0.55 } // ---- the rh-overloads must equal "compute the band then call the precomputed overload" (same code path) ---- [Fact] public void Offense_rh_overload_equals_precomputed_mDeal() { var s = T.Snap(level: 50, kills: 10, deaths: 4, streak: 5, upgrades: MaxedOffense); var rh = T.Resolved(); double viaRh = CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), rh, T.Abil(), T.BaseMaxHp); double viaBand = CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), T.Abil(), T.BaseMaxHp, HandicapModel.MDeal(s, rh)); Assert.Equal(viaBand, viaRh); } [Fact] public void Defense_rh_overload_equals_precomputed_mTake() { var s = T.Snap(level: 50, kills: 10, deaths: 4, streak: 5, adrenaline: true); var rh = T.Resolved(); double viaRh = CombatResolver.DefenseMultiplier(s, true, rh, T.Abil()); double viaBand = CombatResolver.DefenseMultiplier(s, true, T.Abil(), HandicapModel.MTake(s, rh)); Assert.Equal(viaBand, viaRh); } }