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