initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

View file

@ -0,0 +1,136 @@
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<string, int> 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);
}
}