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,112 @@
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// The DESIGN intent, encoded as magic-number-free properties — the real correctness net (the anchors only lock values).
// Headline rule: "the better you are, the harder it gets and the more XP you earn; the worse you are, the easier it gets but
// the less XP." These guard against a future tuning that silently re-inverts it.
public class BalanceInvariantTests
{
private const double Eps = 1e-12;
private static double Offense(PlayerSnapshot s, ResolvedHandicap rh) =>
CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, T.StatDefs(), rh, T.Abil(), T.BaseMaxHp);
private static void AssertBandsTrackT(PlayerSnapshot s, ResolvedHandicap rh,
ref double prevT, ref double prevDeal, ref double prevTake, ref double prevXp, string ctx)
{
double t = HandicapModel.ComputeT(s, rh);
HandicapModel.Bands(s, rh, out double deal, out double take, out double xp);
Assert.True(t >= prevT - Eps, $"{ctx}: t should not decrease ({t} < {prevT})");
Assert.True(deal <= prevDeal + Eps, $"{ctx}: deal should not increase ({deal} > {prevDeal})");
Assert.True(take >= prevTake - Eps, $"{ctx}: take should not decrease ({take} < {prevTake})");
Assert.True(xp >= prevXp - Eps, $"{ctx}: xp should not decrease ({xp} < {prevXp})");
prevT = t; prevDeal = deal; prevTake = take; prevXp = xp;
}
[Fact]
public void Rising_kd_makes_it_harder_and_more_xp()
{
var rh = T.Resolved();
double pt = -2, pd = double.MaxValue, ptk = -2, px = -2;
for (int kills = 0; kills <= 40; kills++)
AssertBandsTrackT(T.Snap(level: 50, kills: kills, deaths: 5), rh, ref pt, ref pd, ref ptk, ref px, $"kills={kills}");
}
[Fact]
public void Rising_level_at_neutral_kd_makes_it_harder()
{
var rh = T.Resolved();
double pt = -2, pd = double.MaxValue, ptk = -2, px = -2;
for (int level = 0; level <= 100; level += 5)
AssertBandsTrackT(T.Snap(level: level, kills: 3, deaths: 2), rh, ref pt, ref pd, ref ptk, ref px, $"level={level}"); // K/D=1.5=neutral
}
[Fact]
public void Rising_streak_makes_it_harder()
{
var rh = T.Resolved();
double pt = -2, pd = double.MaxValue, ptk = -2, px = -2;
for (int streak = 0; streak <= 30; streak++)
AssertBandsTrackT(T.Snap(level: 10, kills: 3, deaths: 2, streak: streak), rh, ref pt, ref pd, ref ptk, ref px, $"streak={streak}");
}
[Fact]
public void Dominant_player_is_net_nerfed_versus_a_fresh_neutral_one()
{
var rh = T.Resolved();
var neutral = T.Snap(level: 0); // no stats, no record
var dominant = T.Snap(level: 100, kills: 40, deaths: 5, headshotKills: 24, streak: 20,
upgrades: new(StringComparer.Ordinal) { [StatKeys.Damage] = 5 }); // even WITH maxed damage stat
// The headline retune property: the deal band (~0.1 at t=1) outweighs the stat bonus, so the dominant player's
// EFFECTIVE offense is below a fresh player's — being good is a net handicap on damage dealt.
Assert.True(Offense(dominant, rh) < Offense(neutral, rh),
$"dominant offense {Offense(dominant, rh)} should be < neutral {Offense(neutral, rh)}");
// ...but they take far more, and earn far more XP (the reward side of "harder").
Assert.True(HandicapModel.MTake(dominant, rh) > HandicapModel.MTake(neutral, rh));
Assert.True(HandicapModel.XpMult(dominant, rh) > HandicapModel.XpMult(neutral, rh));
}
[Fact]
public void Struggling_player_gets_easier_combat_but_less_xp()
{
var rh = T.Resolved();
var neutral = T.Snap(level: 0);
var struggling = T.Snap(level: 10, kills: 1, deaths: 10);
Assert.True(HandicapModel.MDeal(struggling, rh) > HandicapModel.MDeal(neutral, rh)); // deals more
Assert.True(HandicapModel.MTake(struggling, rh) < HandicapModel.MTake(neutral, rh)); // takes less
Assert.True(HandicapModel.XpMult(struggling, rh) < HandicapModel.XpMult(neutral, rh)); // earns less (the cost of comeback help)
}
[Fact]
public void T_is_always_within_unit_interval_even_at_extremes()
{
var rh = T.Resolved();
foreach (int level in new[] { 0, 100, 1000 })
foreach (int kills in new[] { 0, 1, 50, 100000 })
foreach (int deaths in new[] { 0, 1, 50 })
foreach (int streak in new[] { 0, 100 })
foreach (double floor in new[] { -1.0, 0.5, 2.0 })
{
double t = HandicapModel.ComputeT(T.Snap(level: level, kills: kills, deaths: deaths, streak: streak, floor: floor), rh);
Assert.InRange(t, -1.0, 1.0);
}
}
[Fact]
public void Per_mode_override_applies_only_named_fields()
{
// The survival override down-weights the skill factors so the WAVE FLOOR drives difficulty; everything else inherits.
var baseH = T.Hcap();
var ov = T.Surv().Handicap!; // the default survival override
var eff = ov.ApplyTo(baseH);
Assert.Equal(0.3, eff.KdWeight); // overridden
Assert.Equal(10.0, eff.MTakeCeiling); // overridden
Assert.Equal(baseH.MasterDifficulty, eff.MasterDifficulty); // inherited from base (not set in the override)
Assert.Equal(baseH.Curve, eff.Curve); // inherited
}
}

View file

@ -0,0 +1,46 @@
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// CombatResolver's pure reactive-sustain amounts (the engine does the HP/armor writes; these own the numbers). Expected
// ints are hand-known, exercising the (int)Math.Round (banker's, ToEven) rounding + the LifestealMinHeal floor.
public class CombatAmountsTests
{
// LifestealHeal = max(minHeal, (int)Round(dmgHealth * pct/100 * critMult)).
[Theory]
[InlineData(100, 10, 1.0, 2, 10)] // clean
[InlineData(100, 10, 1.5, 2, 15)] // crit multiplier
[InlineData(10, 25, 1.0, 2, 2)] // 2.5 -> ToEven -> 2
[InlineData(30, 25, 1.0, 2, 8)] // 7.5 -> ToEven -> 8
[InlineData(4, 25, 1.0, 2, 2)] // 1.0 -> 1, but minHeal floor binds -> 2
[InlineData(0, 10, 1.0, 2, 2)] // 0 -> floor binds
public void LifestealHeal_matches(double dmgHealth, double pct, double critMult, int minHeal, int expected) =>
Assert.Equal(expected, CombatResolver.LifestealHeal(dmgHealth, pct, critMult, minHeal));
[Fact]
public void LifestealHeal_accepts_fractional_thorns_input()
{
// The thorns-reflect path passes a FLOAT dmgHealth (no pre-truncation) with critMult 1.0. 33.6*10/100 = 3.36 -> 3.
Assert.Equal(3, CombatResolver.LifestealHeal(33.6, 10, 1.0, 2));
}
// ArmorLifestealGain = (int)Round(dmgHealth * pct/100 * critMult). No floor.
[Theory]
[InlineData(100, 10, 1.0, 10)]
[InlineData(10, 25, 1.0, 2)] // 2.5 -> 2
[InlineData(30, 25, 1.0, 8)] // 7.5 -> 8
[InlineData(0, 10, 1.0, 0)] // no floor -> 0
public void ArmorLifestealGain_matches(double dmgHealth, double pct, double critMult, int expected) =>
Assert.Equal(expected, CombatResolver.ArmorLifestealGain(dmgHealth, pct, critMult));
// ThornsReflect = (dmgHealth + dmgArmor) * pct/100 (a double). The caller deals it FLAT — no build/handicap re-applied —
// so the bot eats exactly this off the damage ACTUALLY taken (handicap already baked into dmg*).
[Theory]
[InlineData(50, 30, 10, 8.0)]
[InlineData(100, 0, 5, 5.0)]
[InlineData(250, 0, 10, 25.0)] // the 5x-handicap example: take 250, reflect 10% = 25
[InlineData(0, 0, 10, 0.0)]
public void ThornsReflect_matches(int dmgHealth, int dmgArmor, double pct, double expected) =>
T.Close(expected, CombatResolver.ThornsReflect(dmgHealth, dmgArmor, pct));
}

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);
}
}

View file

@ -0,0 +1,125 @@
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// HandicapModel: the balance spine. ComputeT -> the deal/take/xp bands. Anchor t/band values were produced by an INDEPENDENT
// Python re-implementation of the documented formula against the retuned code defaults (MasterDifficulty 1.6, Curve 1.3,
// KdNeutral 1.5/MaxNerf 4.0, weights Kd2/Hs1/Streak1/Level1.5, bands deal 0.033..1.3 / take 0.85..8 / xp 0.1..10), so these
// validate the C# implementation rather than merely locking it. Fractional-t rows route through Math.Pow -> compared at 1e-9.
public class HandicapModelTests
{
// level, kills, deaths, headshotKills, streak, floor | expected t, deal, take, xp
[Theory]
[InlineData(0, 0, 0, 0, 0, -1.0, /**/ 0.0, 1.0, 1.0, 1.0)] // pure neutral
[InlineData(100, 40, 5, 24, 20, -1.0, /**/ 1.0, 0.03300000000000003, 8.0, 10.0)] // dominant maxed -> t=1
[InlineData(10, 1, 10, 0, 0, -1.0, /**/ -1.0, 1.3, 0.85, 0.09999999999999998)] // struggling -> t=-1 (comeback)
[InlineData(50, 10, 4, 0, 5, -1.0, /**/ 0.4312595622635058, 0.5829720032911898, 4.018816935844541, 4.881336060371552)] // mid lead
[InlineData(1, 20, 4, 0, 0, -1.0, /**/ 0.4993925356559294, 0.5170874180207163, 4.495747749591506, 5.494532820903364)] // high K/D, low level
[InlineData(100, 3, 2, 0, 0, -1.0, /**/ 0.3402539047656616, 0.6709744740916053, 3.381777333359631, 4.062285142890954)] // maxed level at neutral K/D (residual)
[InlineData(10, 1, 10, 0, 0, 0.5, /**/ 0.40612619817811774, 0.6072759663617602, 3.842883387246824, 4.655135783603059)] // struggler RAISED by survival floor 0.5
public void ComputeT_and_bands_match_oracle(int level, int kills, int deaths, int hsk, int streak, double floor,
double et, double ed, double etk, double exp)
{
var s = T.Snap(level: level, kills: kills, deaths: deaths, headshotKills: hsk, streak: streak, floor: floor);
var rh = T.Resolved();
double t = HandicapModel.ComputeT(s, rh);
T.Close(et, t);
HandicapModel.Bands(s, rh, out double deal, out double take, out double xp);
T.Close(ed, deal);
T.Close(etk, take);
T.Close(exp, xp);
// Bands must be bit-identical to the individual band functions (the HUD shares one ComputeT via Bands).
Assert.Equal(HandicapModel.MDeal(s, rh), deal);
Assert.Equal(HandicapModel.MTake(s, rh), take);
Assert.Equal(HandicapModel.XpMult(s, rh), xp);
}
// BandsFromT = the balance API's curve sampler: RAW t in, the same Curve ease applied inside, team multipliers
// neutral. Equivalence with the live path is proven by routing the SAME raw t through ComputeT via the survival
// floor on an all-zero-factor snapshot (floor >= 0 forces t = floor before the shared ease).
[Theory]
[InlineData(0.0)]
[InlineData(0.25)]
[InlineData(0.5)]
[InlineData(1.0)]
public void BandsFromT_matches_live_bands_at_same_raw_t(double t)
{
var rh = T.Resolved();
var s = T.Snap(level: 0, floor: t); // level: 0 — Snap's level=1 default is a nonzero factor that would shift t off the floor
HandicapModel.BandsFromT(t, rh, out double d, out double tk, out double x);
HandicapModel.Bands(s, rh, out double liveDeal, out double liveTake, out double liveXp);
Assert.Equal(liveDeal, d);
Assert.Equal(liveTake, tk);
Assert.Equal(liveXp, x);
}
[Fact]
public void BandsFromT_disabled_is_flat_neutral() // mirrors ComputeT's Enabled short-circuit — sampled curves must be x1 too
{
var h = T.Hcap();
h.Enabled = false;
var rh = T.Resolved(h);
foreach (var t in new[] { -1.0, -0.4, 0.0, 0.7, 1.0 })
{
HandicapModel.BandsFromT(t, rh, out double d, out double tk, out double x);
Assert.Equal(1.0, d);
Assert.Equal(1.0, tk);
Assert.Equal(1.0, x);
}
}
[Fact]
public void BandsFromT_extremes_hit_the_configured_bands() // ease(+-1) = +-1 for ANY Curve, so t=+-1 pins the bands
{
// T.Close, not Assert.Equal: Lerp(1, band, 1) reconstructs the band with float dust (same reason the oracle
// rows above encode 0.03300000000000003 for the 0.033 deal floor).
var h = T.Hcap();
var rh = T.Resolved(h);
HandicapModel.BandsFromT(1.0, rh, out double d1, out double t1, out double x1);
T.Close(h.MDealFloor, d1);
T.Close(h.MTakeCeiling, t1);
T.Close(h.XpCeiling, x1);
HandicapModel.BandsFromT(-1.0, rh, out double d2, out double t2, out double x2);
T.Close(h.MDealCeiling, d2);
T.Close(h.MTakeFloor, t2);
T.Close(h.XpFloor, x2);
}
[Fact]
public void Disabled_handicap_is_neutral()
{
var h = T.Hcap();
h.Enabled = false;
var rh = T.Resolved(h);
var dominant = T.Snap(level: 100, kills: 40, deaths: 5, headshotKills: 24, streak: 20);
Assert.Equal(0.0, HandicapModel.ComputeT(dominant, rh));
HandicapModel.Bands(dominant, rh, out double deal, out double take, out double xp);
Assert.Equal(1.0, deal);
Assert.Equal(1.0, take);
Assert.Equal(1.0, xp);
}
[Fact]
public void Team_card_multipliers_ride_on_top_of_the_band()
{
// Neutral t=0 -> dealFromT=takeFromT=1, so the survival squad card shows through directly. XP is NOT team-scaled.
var rh = T.Resolved();
var s = T.Snap(level: 0, teamDeal: 1.331, teamTake: 0.729);
T.Close(1.331, HandicapModel.MDeal(s, rh));
T.Close(0.729, HandicapModel.MTake(s, rh));
T.Close(1.0, HandicapModel.XpMult(s, rh)); // xp band ignores team mults
}
[Fact]
public void Floor_raises_but_never_lowers_t()
{
var rh = T.Resolved();
// A dominant player (t would be ~1) with a LOW floor is unaffected; the floor only ever pulls a low t UP.
var dominant = T.Snap(level: 100, kills: 40, deaths: 5, headshotKills: 24, streak: 20, floor: 0.2);
Assert.Equal(1.0, HandicapModel.ComputeT(dominant, rh)); // floor 0.2 < natural 1.0 -> no effect
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Outnumbered.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="../Outnumbered/Domain/*.cs">
<Link>Linked/Domain/%(Filename)%(Extension)</Link>
</Compile>
<Compile Include="../Outnumbered/Config/DomainConfig.cs">
<Link>Linked/Config/DomainConfig.cs</Link>
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,54 @@
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// ProgressionModel: XP curve + prestige boost. Expected XP values are INDEPENDENT literals (computed offline from the
// documented formula), so these catch an implementation typo, not just lock current output.
public class ProgressionModelTests
{
// XpToNext = LevelXpBase + round(LevelXpStep * (max(0,L-1))^LevelXpExponent); defaults Base=100, Step=8, Exp=1.7.
[Theory]
[InlineData(1, 100)] // (L-1)=0 -> 0 -> just the base
[InlineData(2, 108)]
[InlineData(3, 126)]
[InlineData(5, 184)]
[InlineData(10, 435)]
[InlineData(25, 1876)]
[InlineData(50, 6076)]
[InlineData(100, 19855)]
public void XpToNext_matches_curve(int level, long expected) =>
Assert.Equal(expected, ProgressionModel.XpToNext(level, T.Prog()));
[Theory]
[InlineData(0)]
[InlineData(-5)]
public void XpToNext_floors_negative_level_at_base(int level) =>
Assert.Equal(100L, ProgressionModel.XpToNext(level, T.Prog())); // Math.Max(0, L-1) guards the Pow base
[Fact]
public void XpToNext_is_strictly_increasing_above_level_one()
{
var c = T.Prog();
long prev = ProgressionModel.XpToNext(1, c);
for (int l = 2; l <= c.LevelCap; l++)
{
long cur = ProgressionModel.XpToNext(l, c);
Assert.True(cur > prev, $"XpToNext({l})={cur} should exceed XpToNext({l - 1})={prev}");
prev = cur;
}
}
// PrestigeXpMultiplier = 1 + prestige * (PrestigeXpBoostPercent/100); default 10% per prestige.
[Theory]
[InlineData(0, 1.00)]
[InlineData(1, 1.10)]
[InlineData(5, 1.50)]
[InlineData(10, 2.00)]
public void PrestigeXpMultiplier_matches(int prestige, double expected) =>
T.Close(expected, ProgressionModel.PrestigeXpMultiplier(prestige, T.Prog()));
[Fact]
public void Retune_pinned_prestige_boost_is_ten_percent() =>
Assert.Equal(10, T.Prog().PrestigeXpBoostPercent); // pins the balance-retune knob
}

View file

@ -0,0 +1,108 @@
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// StatResolver: effective stat values from invested levels + run-card bonuses, plus MaxHp/MaxArmor and the missing-HP
// fraction (Berserk driver). Stat defaults (StatsConfig): damage Base0/+10, crit_damage Base100/+10, max_hp Base0/+25.
public class StatResolverTests
{
private static Dictionary<string, int> Up(params (string key, int lvl)[] e) =>
e.ToDictionary(x => x.key, x => x.lvl, StringComparer.Ordinal);
[Theory]
[InlineData(0, 0)] // base 0
[InlineData(3, 30)] // +10/lvl
[InlineData(5, 50)] // maxed
public void Eff_damage_is_base_plus_level(int level, double expected) =>
Assert.Equal(expected, StatResolver.Eff(T.Snap(upgrades: Up((StatKeys.Damage, level))), StatKeys.Damage, T.StatDefs()));
[Theory]
[InlineData(0, 100)] // crit_damage has Base=100
[InlineData(10, 200)] // +10/lvl maxed
public void Eff_honours_base_value(int level, double expected) =>
Assert.Equal(expected, StatResolver.Eff(T.Snap(upgrades: Up((StatKeys.CritDamage, level))), StatKeys.CritDamage, T.StatDefs()));
[Fact]
public void Eff_unknown_key_is_zero() =>
Assert.Equal(0.0, StatResolver.Eff(T.Snap(), "no_such_stat", T.StatDefs()));
[Fact]
public void Eff_null_upgrades_yields_base_only()
{
// The pd-less / no-investment snapshot: Upgrades null -> LevelOf returns 0 -> Base only (crit_damage Base=100).
var s = T.Snap() with { Upgrades = null! };
Assert.Equal(100.0, StatResolver.Eff(s, StatKeys.CritDamage, T.StatDefs()));
}
[Fact]
public void EffRun_adds_card_bonus_on_top_of_permanent()
{
var s = T.Snap(upgrades: Up((StatKeys.Damage, 5)), cards: new Cards((StatKeys.Damage, 100)));
Assert.Equal(50.0, StatResolver.Eff(s, StatKeys.Damage, T.StatDefs())); // permanent only
Assert.Equal(150.0, StatResolver.EffRun(s, StatKeys.Damage, T.StatDefs())); // + card
}
[Fact]
public void EffRun_without_cards_equals_eff()
{
var s = T.Snap(upgrades: Up((StatKeys.Damage, 3)));
Assert.Equal(StatResolver.Eff(s, StatKeys.Damage, T.StatDefs()),
StatResolver.EffRun(s, StatKeys.Damage, T.StatDefs()));
}
[Theory]
[InlineData(0, 100)] // base 100 HP, no investment
[InlineData(10, 350)] // +25/lvl * 10 = +250
public void MaxHp_is_base_plus_effrun(int level, int expected) =>
Assert.Equal(expected, StatResolver.MaxHp(T.Snap(upgrades: Up((StatKeys.MaxHp, level))), T.StatDefs(), T.BaseMaxHp));
[Fact]
public void MaxHp_includes_card_points_and_truncates()
{
// max_hp level 10 (=250) + a flat +40 card -> 100 + (int)290 = 390.
var s = T.Snap(upgrades: Up((StatKeys.MaxHp, 10)), cards: new Cards((StatKeys.MaxHp, 40)));
Assert.Equal(390, StatResolver.MaxHp(s, T.StatDefs(), T.BaseMaxHp));
}
[Theory]
[InlineData(0, 100)]
[InlineData(10, 350)]
public void MaxArmor_is_base_plus_effrun(int level, int expected) =>
Assert.Equal(expected, StatResolver.MaxArmor(T.Snap(upgrades: Up((StatKeys.MaxArmor, level))), T.StatDefs(), T.BaseMaxArmor));
[Fact]
public void CritChance_reads_the_crit_chance_stat() =>
Assert.Equal(50.0, CombatResolver.CritChance(T.Snap(upgrades: Up((StatKeys.CritChance, 20))), T.StatDefs())); // +2.5/lvl * 20
[Fact]
public void CardMag_returns_effect_card_value_or_zero()
{
var s = T.Snap(cards: new Cards((CardKeys.BerserkPassive, 120)));
Assert.Equal(120.0, StatResolver.CardMag(s, CardKeys.BerserkPassive));
Assert.Equal(0.0, StatResolver.CardMag(s, CardKeys.HsReduction)); // key not present
Assert.Equal(0.0, StatResolver.CardMag(T.Snap(), CardKeys.BerserkPassive)); // no cards at all
}
// ---- MissingHpFraction: 0 at full, ->1 near death, and the §3 settled delta: 0 (not 1) for a dead / pawn-less snapshot ----
[Theory]
[InlineData(100, 100, 0.0)] // full
[InlineData(50, 100, 0.5)]
[InlineData(1, 100, 0.99)] // near death
public void MissingHpFraction_scales_with_missing_health(int health, int maxHp, double expected) =>
T.Close(expected, StatResolver.MissingHpFraction(T.Snap(health: health), maxHp));
[Theory]
[InlineData(0)] // §3 DELTA #2: Health<=0 (no live pawn captured) -> 0, NOT 1. Missing-HP scaling needs a live attacker.
[InlineData(-5)]
public void MissingHpFraction_is_zero_for_dead_or_pawnless(int health) =>
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: health), 100));
[Fact]
public void MissingHpFraction_zero_for_nonpositive_maxhp() =>
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: 50), 0));
[Fact]
public void MissingHpFraction_clamps_overheal_to_zero() =>
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: 150), 100)); // 1 - 1.5 = -0.5 -> 0
}

View file

@ -0,0 +1,113 @@
using Outnumbered.Config;
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// SurvivalEconomy: wave population/budget/escalation + the PER-WAVE XP grant. Defaults (DomainConfig.SurvivalConfig):
// AliveBase=4, AlivePerWave=2, AliveCap=20; BudgetBase=6, BudgetPerWave=4, BudgetPerPlayer=3; MaxNerfWave=25;
// WaveCount=20, WinMult=24. XP is granted per cleared wave: rawWaveXp x prestige x WaveMult(wave) (no cap, no XpBoost,
// handicap excluded). WaveMult(w) = WinMult^((w-1)/(WaveCount-1)).
public class SurvivalEconomyTests
{
// AliveForWave = clamp(AliveBase + AlivePerWave*(wave-1), 1, AliveCap).
[Theory]
[InlineData(1, 4)] // base
[InlineData(2, 6)]
[InlineData(9, 20)] // 4 + 2*8 = 20 -> hits cap
[InlineData(50, 20)] // clamped to AliveCap
public void AliveForWave_ramps_then_caps(int wave, int expected) =>
Assert.Equal(expected, SurvivalEconomy.AliveForWave(wave, T.Surv()));
[Fact]
public void AliveForWave_never_below_one() =>
Assert.Equal(1, SurvivalEconomy.AliveForWave(-5, T.Surv())); // lower clamp
// WaveBudget = max(1, BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumans).
[Theory]
[InlineData(1, 1, 9)] // 6 + 0 + 3*1
[InlineData(1, 4, 18)] // 6 + 0 + 3*4
[InlineData(5, 3, 31)] // 6 + 16 + 9
public void WaveBudget_matches(int wave, int aliveHumans, int expected) =>
Assert.Equal(expected, SurvivalEconomy.WaveBudget(wave, aliveHumans, T.Surv()));
[Fact]
public void WaveBudget_never_below_one() =>
Assert.Equal(1, SurvivalEconomy.WaveBudget(-10, 0, T.Surv()));
// HandicapFloor = wave<=0 ? -1 : min(1, wave/max(1,MaxNerfWave)). Monotonic escalate-only floor in t-space.
[Theory]
[InlineData(0, -1.0)] // idle / between runs
[InlineData(-3, -1.0)]
[InlineData(1, 0.04)] // 1/25
[InlineData(25, 1.0)] // reaches full nerf at MaxNerfWave
[InlineData(40, 1.0)] // clamped at 1
public void HandicapFloor_matches(int wave, double expected) =>
T.Close(expected, SurvivalEconomy.HandicapFloor(wave, T.Surv()));
[Fact]
public void HandicapFloor_is_monotonic_non_decreasing()
{
var c = T.Surv();
double prev = SurvivalEconomy.HandicapFloor(1, c);
for (int w = 2; w <= c.WaveCount; w++)
{
double cur = SurvivalEconomy.HandicapFloor(w, c);
Assert.True(cur >= prev, $"floor wave {w}={cur} < wave {w - 1}={prev}");
prev = cur;
}
}
// WaveMult(w) = WinMult ^ ((w-1)/(WaveCount-1)). Use a clean config (WinMult 100, WaveCount 11) for exact landmarks.
private static SurvivalConfig Clean() => new() { WinMult = 100, WaveCount = 11 };
[Theory]
[InlineData(1, 1.0)] // wave 1 -> x1
[InlineData(6, 10.0)] // midpoint -> 100^0.5 = 10
[InlineData(11, 100.0)] // final wave -> xWinMult
public void WaveMult_ramps_one_to_winmult(int wave, double expected) =>
T.Close(expected, SurvivalEconomy.WaveMult(wave, Clean()));
[Fact]
public void WaveMult_endpoints_on_defaults()
{
var c = T.Surv();
T.Close(1.0, SurvivalEconomy.WaveMult(1, c)); // wave 1 always x1
T.Close(c.WinMult, SurvivalEconomy.WaveMult(c.WaveCount, c)); // final wave = WinMult
}
[Fact]
public void Retune_pinned_winmult_default() => Assert.Equal(24.0, T.Surv().WinMult); // pins the survival XP knob
// WaveXpLump = floor(rawWaveXp * prestigeMult * WaveMult(wave)); NO cap, NO XpBoost, handicap excluded.
[Theory]
[InlineData(1000, 6, 0, 10000)] // 1000 * 1.0 * 10
[InlineData(1000, 11, 0, 100000)] // 1000 * 1.0 * 100
[InlineData(1000, 1, 5, 1500)] // 1000 * 1.5(prestige) * 1
[InlineData(1000, 6, 10, 20000)] // 1000 * 2.0(prestige) * 10
public void WaveXpLump_matches(double rawWaveXp, int wave, int prestige, long expected) =>
Assert.Equal(expected, SurvivalEconomy.WaveXpLump(rawWaveXp, wave, prestige, Clean(), T.Prog()));
[Theory]
[InlineData(0)]
[InlineData(-50)]
public void WaveXpLump_zero_for_nonpositive(double rawWaveXp) =>
Assert.Equal(0L, SurvivalEconomy.WaveXpLump(rawWaveXp, 6, 0, Clean(), T.Prog()));
// AccrueWaveXp = amount * (1 + xpMultCard/100).
[Theory]
[InlineData(100, 0, 100)]
[InlineData(100, 50, 150)]
public void AccrueWaveXp_matches(double amount, double cardPct, double expected) =>
T.Close(expected, SurvivalEconomy.AccrueWaveXp(amount, cardPct));
// TeamMult: compounding per-level squad card. increase -> (1+p/100)^L ; decrease -> max(0,1-p/100)^L.
[Theory]
[InlineData(3, 10, true, 1.3310000000000004)]
[InlineData(3, 10, false, 0.7290000000000001)]
[InlineData(0, 10, true, 1.0)]
[InlineData(3, 100, false, 0.0)] // 1-100% = 0, floored
[InlineData(2, 50, true, 2.25)]
public void TeamMult_matches(int level, double perPick, bool increase, double expected) =>
T.Close(expected, SurvivalEconomy.TeamMult(level, perPick, increase));
}

View file

@ -0,0 +1,93 @@
using System.Collections.Frozen;
using Outnumbered.Config;
using Outnumbered.Domain;
using Xunit;
namespace Outnumbered.Tests;
// Shared fixtures for the Domain tests. The config objects' parameterless constructors already carry the SHIPPING code
// defaults (the balance-retune values in DomainConfig.cs are those defaults), so `new HandicapConfig()` etc. is exactly the
// production config — the live OP testing JSON is deliberately NOT involved (balance source of truth = code defaults).
internal static class T
{
public const int BaseMaxHp = 100; // SnapshotBuilder.BaseMaxHp
public const int BaseMaxArmor = 100; // SnapshotBuilder.BaseMaxArmor
public static HandicapConfig Hcap() => new();
public static ProgressionConfig Prog() => new();
public static StatsConfig Stats() => new();
public static SurvivalConfig Surv() => new();
public static AbilitiesConfig Abil() => new();
public static ResolvedHandicap Resolved() => new(Hcap());
public static ResolvedHandicap Resolved(HandicapConfig h) => new(h);
// Mirrors SnapshotBuilder.RebuildStatDefs + Stats.StatRegistry EXACTLY (same 12 key->StatDef rows, StringComparer.Ordinal).
// If StatRegistry changes, this must change with it — the load-time validators in the engine keep the live registry honest;
// this is the test-side twin.
public static FrozenDictionary<string, StatDef> StatDefs() => StatDefs(Stats());
public static FrozenDictionary<string, StatDef> StatDefs(StatsConfig c) => new Dictionary<string, StatDef>(StringComparer.Ordinal)
{
[StatKeys.Damage] = c.Damage,
[StatKeys.CritChance] = c.CritChance,
[StatKeys.CritDamage] = c.CritDamage,
[StatKeys.HeadshotDamage] = c.HeadshotDamage,
[StatKeys.MaxHp] = c.MaxHp,
[StatKeys.MaxArmor] = c.MaxArmor,
[StatKeys.Lifesteal] = c.Lifesteal,
[StatKeys.ArmorLifesteal] = c.ArmorLifesteal,
[StatKeys.HpRegen] = c.HpRegen,
[StatKeys.ArmorRegen] = c.ArmorRegen,
[StatKeys.Thorns] = c.Thorns,
[StatKeys.XpBoost] = c.XpBoost,
}.ToFrozenDictionary(StringComparer.Ordinal);
// A PlayerSnapshot factory: neutral by default (kills+deaths<3 so the K/D factor is dormant, full HP, no floor/team buff,
// no abilities). Override only the fields a case cares about.
public static PlayerSnapshot Snap(
int level = 1, int prestige = 0, long xp = 0,
int kills = 0, int deaths = 0, int headshotKills = 0, int streak = 0,
int health = 100,
double progress = 0.0, double floor = -1.0,
double teamDeal = 1.0, double teamTake = 1.0,
bool overcharge = false, bool berserk = false, bool adrenaline = false,
Dictionary<string, int>? upgrades = null, IStatBonusSource? cards = null) => new()
{
Level = level,
Prestige = prestige,
Xp = xp,
Kills = kills,
Deaths = deaths,
HeadshotKills = headshotKills,
Streak = streak,
Health = health,
HandicapProgress = progress,
HandicapFloor = floor,
TeamDealMult = teamDeal,
TeamTakeMult = teamTake,
OverchargeActive = overcharge,
BerserkActive = berserk,
AdrenalineActive = adrenaline,
Upgrades = upgrades ?? new Dictionary<string, int>(),
Cards = cards,
};
// Relative-tolerance double compare. The fractional-t bands route through Math.Pow, whose last ULP can differ from the
// independent Python oracle that produced the literals; 1e-9 is microscopic next to any real formula change (which moves a
// value by whole percent), so this still catches every regression while ignoring FP noise.
public static void Close(double expected, double actual, double rel = 1e-9)
{
double tol = rel * Math.Max(1.0, Math.Abs(expected));
Assert.True(Math.Abs(expected - actual) <= tol,
$"expected {expected:R}, got {actual:R} (|diff|={Math.Abs(expected - actual):R} > tol={tol:R})");
}
}
// Dictionary-backed IStatBonusSource — stands in for the survival run's per-stat card accumulator. The Domain only ever calls
// Bonus(key); the engine-side level*PerPick accumulation isn't Domain logic, so a flat dictionary is the faithful test double.
internal sealed class Cards(params (string key, double bonus)[] entries) : IStatBonusSource
{
private readonly Dictionary<string, double> _b = entries.ToDictionary(e => e.key, e => e.bonus, StringComparer.Ordinal);
public double Bonus(string statKey) => _b.TryGetValue(statKey, out var v) ? v : 0.0;
}