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