cs2-outnumbered/Outnumbered.Tests/HandicapModelTests.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

125 lines
6.1 KiB
C#

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