112 lines
5.5 KiB
C#
112 lines
5.5 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 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
|
|
}
|
|
}
|