142 lines
7.3 KiB
C#
142 lines
7.3 KiB
C#
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 zeroes every skill factor so the WAVE FLOOR alone 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.0, eff.KdWeight); // overridden -> in-run performance cannot move the handicap
|
|
Assert.Equal(0.0, eff.HsWeight);
|
|
Assert.Equal(0.0, eff.StreakWeight);
|
|
Assert.Equal(0.0, eff.LevelWeight);
|
|
Assert.Equal(0.0, eff.ProgressWeight);
|
|
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
|
|
}
|
|
|
|
[Fact]
|
|
public void Survival_handicap_is_wave_only_and_invariant_to_performance()
|
|
{
|
|
// Bug fix: survival's handicap must depend ONLY on the wave (via the floor), never on K/D / headshots / streak /
|
|
// level. A dominant player and a struggling player on the SAME wave get the SAME t, and killing more bots inside a
|
|
// wave never changes it — so the 1st and the 100th kill of a wave take identical shots. (Before, K/D + HS saturated
|
|
// after a handful of kills and drove a good player straight to the floor mid-wave-1.)
|
|
var rh = T.Resolved(T.Surv().Handicap!.ApplyTo(T.Hcap()));
|
|
|
|
foreach (var wave in new[] { 1, 5, 12, 25 })
|
|
{
|
|
double floor = SurvivalEconomy.HandicapFloor(wave, T.Surv());
|
|
double dominant = HandicapModel.ComputeT(
|
|
T.Snap(level: 100, kills: 60, deaths: 0, headshotKills: 60, streak: 50, floor: floor), rh);
|
|
double struggling = HandicapModel.ComputeT(
|
|
T.Snap(level: 1, kills: 0, deaths: 20, headshotKills: 0, streak: 0, floor: floor), rh);
|
|
double freshMidWave = HandicapModel.ComputeT(
|
|
T.Snap(level: 1, kills: 7, deaths: 0, headshotKills: 7, streak: 7, floor: floor), rh);
|
|
|
|
Assert.Equal(dominant, struggling, Eps); // same wave -> same handicap, regardless of performance
|
|
Assert.Equal(dominant, freshMidWave, Eps);
|
|
// it IS just the eased wave floor (floor >= 0 for any real wave, so Ease reduces to Pow)
|
|
Assert.Equal(Math.Pow(floor, rh.Config.Curve), dominant, Eps);
|
|
}
|
|
}
|
|
}
|