initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
112
Outnumbered.Tests/BalanceInvariantTests.cs
Normal file
112
Outnumbered.Tests/BalanceInvariantTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
46
Outnumbered.Tests/CombatAmountsTests.cs
Normal file
46
Outnumbered.Tests/CombatAmountsTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using Outnumbered.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace Outnumbered.Tests;
|
||||
|
||||
// CombatResolver's pure reactive-sustain amounts (the engine does the HP/armor writes; these own the numbers). Expected
|
||||
// ints are hand-known, exercising the (int)Math.Round (banker's, ToEven) rounding + the LifestealMinHeal floor.
|
||||
public class CombatAmountsTests
|
||||
{
|
||||
// LifestealHeal = max(minHeal, (int)Round(dmgHealth * pct/100 * critMult)).
|
||||
[Theory]
|
||||
[InlineData(100, 10, 1.0, 2, 10)] // clean
|
||||
[InlineData(100, 10, 1.5, 2, 15)] // crit multiplier
|
||||
[InlineData(10, 25, 1.0, 2, 2)] // 2.5 -> ToEven -> 2
|
||||
[InlineData(30, 25, 1.0, 2, 8)] // 7.5 -> ToEven -> 8
|
||||
[InlineData(4, 25, 1.0, 2, 2)] // 1.0 -> 1, but minHeal floor binds -> 2
|
||||
[InlineData(0, 10, 1.0, 2, 2)] // 0 -> floor binds
|
||||
public void LifestealHeal_matches(double dmgHealth, double pct, double critMult, int minHeal, int expected) =>
|
||||
Assert.Equal(expected, CombatResolver.LifestealHeal(dmgHealth, pct, critMult, minHeal));
|
||||
|
||||
[Fact]
|
||||
public void LifestealHeal_accepts_fractional_thorns_input()
|
||||
{
|
||||
// The thorns-reflect path passes a FLOAT dmgHealth (no pre-truncation) with critMult 1.0. 33.6*10/100 = 3.36 -> 3.
|
||||
Assert.Equal(3, CombatResolver.LifestealHeal(33.6, 10, 1.0, 2));
|
||||
}
|
||||
|
||||
// ArmorLifestealGain = (int)Round(dmgHealth * pct/100 * critMult). No floor.
|
||||
[Theory]
|
||||
[InlineData(100, 10, 1.0, 10)]
|
||||
[InlineData(10, 25, 1.0, 2)] // 2.5 -> 2
|
||||
[InlineData(30, 25, 1.0, 8)] // 7.5 -> 8
|
||||
[InlineData(0, 10, 1.0, 0)] // no floor -> 0
|
||||
public void ArmorLifestealGain_matches(double dmgHealth, double pct, double critMult, int expected) =>
|
||||
Assert.Equal(expected, CombatResolver.ArmorLifestealGain(dmgHealth, pct, critMult));
|
||||
|
||||
// ThornsReflect = (dmgHealth + dmgArmor) * pct/100 (a double). The caller deals it FLAT — no build/handicap re-applied —
|
||||
// so the bot eats exactly this off the damage ACTUALLY taken (handicap already baked into dmg*).
|
||||
[Theory]
|
||||
[InlineData(50, 30, 10, 8.0)]
|
||||
[InlineData(100, 0, 5, 5.0)]
|
||||
[InlineData(250, 0, 10, 25.0)] // the 5x-handicap example: take 250, reflect 10% = 25
|
||||
[InlineData(0, 0, 10, 0.0)]
|
||||
public void ThornsReflect_matches(int dmgHealth, int dmgArmor, double pct, double expected) =>
|
||||
T.Close(expected, CombatResolver.ThornsReflect(dmgHealth, dmgArmor, pct));
|
||||
}
|
||||
136
Outnumbered.Tests/CombatChainTests.cs
Normal file
136
Outnumbered.Tests/CombatChainTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using Outnumbered.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace Outnumbered.Tests;
|
||||
|
||||
// CombatResolver's offense/defense multiplier chains — the single source of truth shared by the damage hook + the HUD.
|
||||
// Anchors are hand-traced from the documented chain (dmgMul*hsMul*critMul*abilityMul*mDeal etc.) with mDeal/mTake passed
|
||||
// explicitly so the handicap band is isolated from the stat/ability composition. Ability defaults: Overcharge.Magnitude=50,
|
||||
// Adrenaline.Magnitude=35, Berserk.Magnitude=1.5/Magnitude2=1.5. crit_damage Base=100 (so a no-investment crit is x2).
|
||||
public class CombatChainTests
|
||||
{
|
||||
|
||||
private static readonly Dictionary<string, int> MaxedOffense =
|
||||
new(StringComparer.Ordinal) { [StatKeys.Damage] = 5, [StatKeys.HeadshotDamage] = 5, [StatKeys.CritDamage] = 10 };
|
||||
|
||||
// ---- OffenseMultiplier (precomputed-mDeal overload) ----
|
||||
[Fact]
|
||||
public void Offense_maxed_headshot_crit()
|
||||
{
|
||||
// dmg +50% (1.5) * hs +50% (1.5) * crit +200% (3.0) * 1 * mDeal(1) = 6.75
|
||||
var s = T.Snap(upgrades: MaxedOffense);
|
||||
T.Close(6.75, CombatResolver.OffenseMultiplier(s, headshot: true, crit: true, T.StatDefs(), T.Abil(), T.BaseMaxHp, mDeal: 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_maxed_bodyshot_no_crit_only_damage_applies()
|
||||
{
|
||||
var s = T.Snap(upgrades: MaxedOffense);
|
||||
T.Close(1.5, CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, T.StatDefs(), T.Abil(), T.BaseMaxHp, mDeal: 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_overcharge_multiplies_by_magnitude()
|
||||
{
|
||||
// no stats, Overcharge active -> abilityMul 1.5
|
||||
var s = T.Snap(overcharge: true);
|
||||
T.Close(1.5, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_berserk_passive_card_scales_with_missing_hp()
|
||||
{
|
||||
// berserk_passive card 120, Health 50/maxHp 100 -> missing 0.5 -> abilityMul 1 + 0.5*120/100 = 1.6
|
||||
var s = T.Snap(health: 50, cards: new Cards((CardKeys.BerserkPassive, 120)));
|
||||
T.Close(1.6, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_berserk_ability_scales_with_missing_hp()
|
||||
{
|
||||
// Berserk ability active, Health 25 -> missing 0.75; abilityMul 1 + 0.75*1.5 = 2.125
|
||||
var s = T.Snap(health: 25, berserk: true);
|
||||
T.Close(2.125, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_berserk_ability_crit_adds_to_crit_damage()
|
||||
{
|
||||
// crit (no crit_damage stat -> base x2 = 2.0) + Berserk crit bonus missing*Magnitude2 = 0.75*1.5 -> critMul 3.125;
|
||||
// abilityMul 2.125 -> 3.125 * 2.125 = 6.640625
|
||||
var s = T.Snap(health: 25, berserk: true);
|
||||
T.Close(6.640625, CombatResolver.OffenseMultiplier(s, false, crit: true, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_abilities_disabled_ignores_active_flags()
|
||||
{
|
||||
var ab = T.Abil();
|
||||
ab.Enabled = false;
|
||||
var s = T.Snap(overcharge: true, berserk: true, health: 25); // flags set but abilities off
|
||||
T.Close(1.0, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), ab, T.BaseMaxHp, 1.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offense_passes_mDeal_through_linearly()
|
||||
{
|
||||
var s = T.Snap(upgrades: MaxedOffense);
|
||||
// 6.75 * 0.1 = 0.675 (a dominant player's compressed deal band)
|
||||
T.Close(0.675, CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), T.Abil(), T.BaseMaxHp, 0.1));
|
||||
}
|
||||
|
||||
// ---- DefenseMultiplier (precomputed-mTake overload) ----
|
||||
[Fact]
|
||||
public void Defense_plain_is_just_mTake() =>
|
||||
T.Close(1.0, CombatResolver.DefenseMultiplier(T.Snap(), headshot: false, T.Abil(), mTake: 1.0));
|
||||
|
||||
[Fact]
|
||||
public void Defense_adrenaline_reduces_take()
|
||||
{
|
||||
var s = T.Snap(adrenaline: true);
|
||||
T.Close(0.65, CombatResolver.DefenseMultiplier(s, false, T.Abil(), 1.0)); // *(1 - 35/100)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defense_headshot_reduction_card_only_on_headshots()
|
||||
{
|
||||
var s = T.Snap(cards: new Cards((CardKeys.HsReduction, 45)));
|
||||
T.Close(1.0, CombatResolver.DefenseMultiplier(s, headshot: false, T.Abil(), 1.0)); // body: card dormant
|
||||
T.Close(0.55, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), 1.0)); // *(1 - 45/100)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defense_headshot_reduction_over_hundred_clamps_to_zero()
|
||||
{
|
||||
var s = T.Snap(cards: new Cards((CardKeys.HsReduction, 120)));
|
||||
T.Close(0.0, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), 1.0)); // max(0, 1-1.2)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defense_stacks_adrenaline_and_hs_card_on_mTake()
|
||||
{
|
||||
var s = T.Snap(adrenaline: true, cards: new Cards((CardKeys.HsReduction, 45)));
|
||||
T.Close(0.715, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), mTake: 2.0)); // 2 * 0.65 * 0.55
|
||||
}
|
||||
|
||||
// ---- the rh-overloads must equal "compute the band then call the precomputed overload" (same code path) ----
|
||||
[Fact]
|
||||
public void Offense_rh_overload_equals_precomputed_mDeal()
|
||||
{
|
||||
var s = T.Snap(level: 50, kills: 10, deaths: 4, streak: 5, upgrades: MaxedOffense);
|
||||
var rh = T.Resolved();
|
||||
double viaRh = CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), rh, T.Abil(), T.BaseMaxHp);
|
||||
double viaBand = CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), T.Abil(), T.BaseMaxHp, HandicapModel.MDeal(s, rh));
|
||||
Assert.Equal(viaBand, viaRh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defense_rh_overload_equals_precomputed_mTake()
|
||||
{
|
||||
var s = T.Snap(level: 50, kills: 10, deaths: 4, streak: 5, adrenaline: true);
|
||||
var rh = T.Resolved();
|
||||
double viaRh = CombatResolver.DefenseMultiplier(s, true, rh, T.Abil());
|
||||
double viaBand = CombatResolver.DefenseMultiplier(s, true, T.Abil(), HandicapModel.MTake(s, rh));
|
||||
Assert.Equal(viaBand, viaRh);
|
||||
}
|
||||
}
|
||||
125
Outnumbered.Tests/HandicapModelTests.cs
Normal file
125
Outnumbered.Tests/HandicapModelTests.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
21
Outnumbered.Tests/Outnumbered.Tests.csproj
Normal file
21
Outnumbered.Tests/Outnumbered.Tests.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Outnumbered.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../Outnumbered/Domain/*.cs">
|
||||
<Link>Linked/Domain/%(Filename)%(Extension)</Link>
|
||||
</Compile>
|
||||
<Compile Include="../Outnumbered/Config/DomainConfig.cs">
|
||||
<Link>Linked/Config/DomainConfig.cs</Link>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
54
Outnumbered.Tests/ProgressionModelTests.cs
Normal file
54
Outnumbered.Tests/ProgressionModelTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using Outnumbered.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace Outnumbered.Tests;
|
||||
|
||||
// ProgressionModel: XP curve + prestige boost. Expected XP values are INDEPENDENT literals (computed offline from the
|
||||
// documented formula), so these catch an implementation typo, not just lock current output.
|
||||
public class ProgressionModelTests
|
||||
{
|
||||
// XpToNext = LevelXpBase + round(LevelXpStep * (max(0,L-1))^LevelXpExponent); defaults Base=100, Step=8, Exp=1.7.
|
||||
[Theory]
|
||||
[InlineData(1, 100)] // (L-1)=0 -> 0 -> just the base
|
||||
[InlineData(2, 108)]
|
||||
[InlineData(3, 126)]
|
||||
[InlineData(5, 184)]
|
||||
[InlineData(10, 435)]
|
||||
[InlineData(25, 1876)]
|
||||
[InlineData(50, 6076)]
|
||||
[InlineData(100, 19855)]
|
||||
public void XpToNext_matches_curve(int level, long expected) =>
|
||||
Assert.Equal(expected, ProgressionModel.XpToNext(level, T.Prog()));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public void XpToNext_floors_negative_level_at_base(int level) =>
|
||||
Assert.Equal(100L, ProgressionModel.XpToNext(level, T.Prog())); // Math.Max(0, L-1) guards the Pow base
|
||||
|
||||
[Fact]
|
||||
public void XpToNext_is_strictly_increasing_above_level_one()
|
||||
{
|
||||
var c = T.Prog();
|
||||
long prev = ProgressionModel.XpToNext(1, c);
|
||||
for (int l = 2; l <= c.LevelCap; l++)
|
||||
{
|
||||
long cur = ProgressionModel.XpToNext(l, c);
|
||||
Assert.True(cur > prev, $"XpToNext({l})={cur} should exceed XpToNext({l - 1})={prev}");
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
// PrestigeXpMultiplier = 1 + prestige * (PrestigeXpBoostPercent/100); default 10% per prestige.
|
||||
[Theory]
|
||||
[InlineData(0, 1.00)]
|
||||
[InlineData(1, 1.10)]
|
||||
[InlineData(5, 1.50)]
|
||||
[InlineData(10, 2.00)]
|
||||
public void PrestigeXpMultiplier_matches(int prestige, double expected) =>
|
||||
T.Close(expected, ProgressionModel.PrestigeXpMultiplier(prestige, T.Prog()));
|
||||
|
||||
[Fact]
|
||||
public void Retune_pinned_prestige_boost_is_ten_percent() =>
|
||||
Assert.Equal(10, T.Prog().PrestigeXpBoostPercent); // pins the balance-retune knob
|
||||
}
|
||||
108
Outnumbered.Tests/StatResolverTests.cs
Normal file
108
Outnumbered.Tests/StatResolverTests.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using Outnumbered.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace Outnumbered.Tests;
|
||||
|
||||
// StatResolver: effective stat values from invested levels + run-card bonuses, plus MaxHp/MaxArmor and the missing-HP
|
||||
// fraction (Berserk driver). Stat defaults (StatsConfig): damage Base0/+10, crit_damage Base100/+10, max_hp Base0/+25.
|
||||
public class StatResolverTests
|
||||
{
|
||||
private static Dictionary<string, int> Up(params (string key, int lvl)[] e) =>
|
||||
e.ToDictionary(x => x.key, x => x.lvl, StringComparer.Ordinal);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)] // base 0
|
||||
[InlineData(3, 30)] // +10/lvl
|
||||
[InlineData(5, 50)] // maxed
|
||||
public void Eff_damage_is_base_plus_level(int level, double expected) =>
|
||||
Assert.Equal(expected, StatResolver.Eff(T.Snap(upgrades: Up((StatKeys.Damage, level))), StatKeys.Damage, T.StatDefs()));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 100)] // crit_damage has Base=100
|
||||
[InlineData(10, 200)] // +10/lvl maxed
|
||||
public void Eff_honours_base_value(int level, double expected) =>
|
||||
Assert.Equal(expected, StatResolver.Eff(T.Snap(upgrades: Up((StatKeys.CritDamage, level))), StatKeys.CritDamage, T.StatDefs()));
|
||||
|
||||
[Fact]
|
||||
public void Eff_unknown_key_is_zero() =>
|
||||
Assert.Equal(0.0, StatResolver.Eff(T.Snap(), "no_such_stat", T.StatDefs()));
|
||||
|
||||
[Fact]
|
||||
public void Eff_null_upgrades_yields_base_only()
|
||||
{
|
||||
// The pd-less / no-investment snapshot: Upgrades null -> LevelOf returns 0 -> Base only (crit_damage Base=100).
|
||||
var s = T.Snap() with { Upgrades = null! };
|
||||
Assert.Equal(100.0, StatResolver.Eff(s, StatKeys.CritDamage, T.StatDefs()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffRun_adds_card_bonus_on_top_of_permanent()
|
||||
{
|
||||
var s = T.Snap(upgrades: Up((StatKeys.Damage, 5)), cards: new Cards((StatKeys.Damage, 100)));
|
||||
Assert.Equal(50.0, StatResolver.Eff(s, StatKeys.Damage, T.StatDefs())); // permanent only
|
||||
Assert.Equal(150.0, StatResolver.EffRun(s, StatKeys.Damage, T.StatDefs())); // + card
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffRun_without_cards_equals_eff()
|
||||
{
|
||||
var s = T.Snap(upgrades: Up((StatKeys.Damage, 3)));
|
||||
Assert.Equal(StatResolver.Eff(s, StatKeys.Damage, T.StatDefs()),
|
||||
StatResolver.EffRun(s, StatKeys.Damage, T.StatDefs()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 100)] // base 100 HP, no investment
|
||||
[InlineData(10, 350)] // +25/lvl * 10 = +250
|
||||
public void MaxHp_is_base_plus_effrun(int level, int expected) =>
|
||||
Assert.Equal(expected, StatResolver.MaxHp(T.Snap(upgrades: Up((StatKeys.MaxHp, level))), T.StatDefs(), T.BaseMaxHp));
|
||||
|
||||
[Fact]
|
||||
public void MaxHp_includes_card_points_and_truncates()
|
||||
{
|
||||
// max_hp level 10 (=250) + a flat +40 card -> 100 + (int)290 = 390.
|
||||
var s = T.Snap(upgrades: Up((StatKeys.MaxHp, 10)), cards: new Cards((StatKeys.MaxHp, 40)));
|
||||
Assert.Equal(390, StatResolver.MaxHp(s, T.StatDefs(), T.BaseMaxHp));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 100)]
|
||||
[InlineData(10, 350)]
|
||||
public void MaxArmor_is_base_plus_effrun(int level, int expected) =>
|
||||
Assert.Equal(expected, StatResolver.MaxArmor(T.Snap(upgrades: Up((StatKeys.MaxArmor, level))), T.StatDefs(), T.BaseMaxArmor));
|
||||
|
||||
[Fact]
|
||||
public void CritChance_reads_the_crit_chance_stat() =>
|
||||
Assert.Equal(50.0, CombatResolver.CritChance(T.Snap(upgrades: Up((StatKeys.CritChance, 20))), T.StatDefs())); // +2.5/lvl * 20
|
||||
|
||||
[Fact]
|
||||
public void CardMag_returns_effect_card_value_or_zero()
|
||||
{
|
||||
var s = T.Snap(cards: new Cards((CardKeys.BerserkPassive, 120)));
|
||||
Assert.Equal(120.0, StatResolver.CardMag(s, CardKeys.BerserkPassive));
|
||||
Assert.Equal(0.0, StatResolver.CardMag(s, CardKeys.HsReduction)); // key not present
|
||||
Assert.Equal(0.0, StatResolver.CardMag(T.Snap(), CardKeys.BerserkPassive)); // no cards at all
|
||||
}
|
||||
|
||||
// ---- MissingHpFraction: 0 at full, ->1 near death, and the §3 settled delta: 0 (not 1) for a dead / pawn-less snapshot ----
|
||||
[Theory]
|
||||
[InlineData(100, 100, 0.0)] // full
|
||||
[InlineData(50, 100, 0.5)]
|
||||
[InlineData(1, 100, 0.99)] // near death
|
||||
public void MissingHpFraction_scales_with_missing_health(int health, int maxHp, double expected) =>
|
||||
T.Close(expected, StatResolver.MissingHpFraction(T.Snap(health: health), maxHp));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // §3 DELTA #2: Health<=0 (no live pawn captured) -> 0, NOT 1. Missing-HP scaling needs a live attacker.
|
||||
[InlineData(-5)]
|
||||
public void MissingHpFraction_is_zero_for_dead_or_pawnless(int health) =>
|
||||
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: health), 100));
|
||||
|
||||
[Fact]
|
||||
public void MissingHpFraction_zero_for_nonpositive_maxhp() =>
|
||||
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: 50), 0));
|
||||
|
||||
[Fact]
|
||||
public void MissingHpFraction_clamps_overheal_to_zero() =>
|
||||
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: 150), 100)); // 1 - 1.5 = -0.5 -> 0
|
||||
}
|
||||
113
Outnumbered.Tests/SurvivalEconomyTests.cs
Normal file
113
Outnumbered.Tests/SurvivalEconomyTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using Outnumbered.Config;
|
||||
using Outnumbered.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace Outnumbered.Tests;
|
||||
|
||||
// SurvivalEconomy: wave population/budget/escalation + the PER-WAVE XP grant. Defaults (DomainConfig.SurvivalConfig):
|
||||
// AliveBase=4, AlivePerWave=2, AliveCap=20; BudgetBase=6, BudgetPerWave=4, BudgetPerPlayer=3; MaxNerfWave=25;
|
||||
// WaveCount=20, WinMult=24. XP is granted per cleared wave: rawWaveXp x prestige x WaveMult(wave) (no cap, no XpBoost,
|
||||
// handicap excluded). WaveMult(w) = WinMult^((w-1)/(WaveCount-1)).
|
||||
public class SurvivalEconomyTests
|
||||
{
|
||||
// AliveForWave = clamp(AliveBase + AlivePerWave*(wave-1), 1, AliveCap).
|
||||
[Theory]
|
||||
[InlineData(1, 4)] // base
|
||||
[InlineData(2, 6)]
|
||||
[InlineData(9, 20)] // 4 + 2*8 = 20 -> hits cap
|
||||
[InlineData(50, 20)] // clamped to AliveCap
|
||||
public void AliveForWave_ramps_then_caps(int wave, int expected) =>
|
||||
Assert.Equal(expected, SurvivalEconomy.AliveForWave(wave, T.Surv()));
|
||||
|
||||
[Fact]
|
||||
public void AliveForWave_never_below_one() =>
|
||||
Assert.Equal(1, SurvivalEconomy.AliveForWave(-5, T.Surv())); // lower clamp
|
||||
|
||||
// WaveBudget = max(1, BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumans).
|
||||
[Theory]
|
||||
[InlineData(1, 1, 9)] // 6 + 0 + 3*1
|
||||
[InlineData(1, 4, 18)] // 6 + 0 + 3*4
|
||||
[InlineData(5, 3, 31)] // 6 + 16 + 9
|
||||
public void WaveBudget_matches(int wave, int aliveHumans, int expected) =>
|
||||
Assert.Equal(expected, SurvivalEconomy.WaveBudget(wave, aliveHumans, T.Surv()));
|
||||
|
||||
[Fact]
|
||||
public void WaveBudget_never_below_one() =>
|
||||
Assert.Equal(1, SurvivalEconomy.WaveBudget(-10, 0, T.Surv()));
|
||||
|
||||
// HandicapFloor = wave<=0 ? -1 : min(1, wave/max(1,MaxNerfWave)). Monotonic escalate-only floor in t-space.
|
||||
[Theory]
|
||||
[InlineData(0, -1.0)] // idle / between runs
|
||||
[InlineData(-3, -1.0)]
|
||||
[InlineData(1, 0.04)] // 1/25
|
||||
[InlineData(25, 1.0)] // reaches full nerf at MaxNerfWave
|
||||
[InlineData(40, 1.0)] // clamped at 1
|
||||
public void HandicapFloor_matches(int wave, double expected) =>
|
||||
T.Close(expected, SurvivalEconomy.HandicapFloor(wave, T.Surv()));
|
||||
|
||||
[Fact]
|
||||
public void HandicapFloor_is_monotonic_non_decreasing()
|
||||
{
|
||||
var c = T.Surv();
|
||||
double prev = SurvivalEconomy.HandicapFloor(1, c);
|
||||
for (int w = 2; w <= c.WaveCount; w++)
|
||||
{
|
||||
double cur = SurvivalEconomy.HandicapFloor(w, c);
|
||||
Assert.True(cur >= prev, $"floor wave {w}={cur} < wave {w - 1}={prev}");
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
// WaveMult(w) = WinMult ^ ((w-1)/(WaveCount-1)). Use a clean config (WinMult 100, WaveCount 11) for exact landmarks.
|
||||
private static SurvivalConfig Clean() => new() { WinMult = 100, WaveCount = 11 };
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1.0)] // wave 1 -> x1
|
||||
[InlineData(6, 10.0)] // midpoint -> 100^0.5 = 10
|
||||
[InlineData(11, 100.0)] // final wave -> xWinMult
|
||||
public void WaveMult_ramps_one_to_winmult(int wave, double expected) =>
|
||||
T.Close(expected, SurvivalEconomy.WaveMult(wave, Clean()));
|
||||
|
||||
[Fact]
|
||||
public void WaveMult_endpoints_on_defaults()
|
||||
{
|
||||
var c = T.Surv();
|
||||
T.Close(1.0, SurvivalEconomy.WaveMult(1, c)); // wave 1 always x1
|
||||
T.Close(c.WinMult, SurvivalEconomy.WaveMult(c.WaveCount, c)); // final wave = WinMult
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retune_pinned_winmult_default() => Assert.Equal(24.0, T.Surv().WinMult); // pins the survival XP knob
|
||||
|
||||
// WaveXpLump = floor(rawWaveXp * prestigeMult * WaveMult(wave)); NO cap, NO XpBoost, handicap excluded.
|
||||
[Theory]
|
||||
[InlineData(1000, 6, 0, 10000)] // 1000 * 1.0 * 10
|
||||
[InlineData(1000, 11, 0, 100000)] // 1000 * 1.0 * 100
|
||||
[InlineData(1000, 1, 5, 1500)] // 1000 * 1.5(prestige) * 1
|
||||
[InlineData(1000, 6, 10, 20000)] // 1000 * 2.0(prestige) * 10
|
||||
public void WaveXpLump_matches(double rawWaveXp, int wave, int prestige, long expected) =>
|
||||
Assert.Equal(expected, SurvivalEconomy.WaveXpLump(rawWaveXp, wave, prestige, Clean(), T.Prog()));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-50)]
|
||||
public void WaveXpLump_zero_for_nonpositive(double rawWaveXp) =>
|
||||
Assert.Equal(0L, SurvivalEconomy.WaveXpLump(rawWaveXp, 6, 0, Clean(), T.Prog()));
|
||||
|
||||
// AccrueWaveXp = amount * (1 + xpMultCard/100).
|
||||
[Theory]
|
||||
[InlineData(100, 0, 100)]
|
||||
[InlineData(100, 50, 150)]
|
||||
public void AccrueWaveXp_matches(double amount, double cardPct, double expected) =>
|
||||
T.Close(expected, SurvivalEconomy.AccrueWaveXp(amount, cardPct));
|
||||
|
||||
// TeamMult: compounding per-level squad card. increase -> (1+p/100)^L ; decrease -> max(0,1-p/100)^L.
|
||||
[Theory]
|
||||
[InlineData(3, 10, true, 1.3310000000000004)]
|
||||
[InlineData(3, 10, false, 0.7290000000000001)]
|
||||
[InlineData(0, 10, true, 1.0)]
|
||||
[InlineData(3, 100, false, 0.0)] // 1-100% = 0, floored
|
||||
[InlineData(2, 50, true, 2.25)]
|
||||
public void TeamMult_matches(int level, double perPick, bool increase, double expected) =>
|
||||
T.Close(expected, SurvivalEconomy.TeamMult(level, perPick, increase));
|
||||
}
|
||||
93
Outnumbered.Tests/TestSupport.cs
Normal file
93
Outnumbered.Tests/TestSupport.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.Collections.Frozen;
|
||||
using Outnumbered.Config;
|
||||
using Outnumbered.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace Outnumbered.Tests;
|
||||
|
||||
// Shared fixtures for the Domain tests. The config objects' parameterless constructors already carry the SHIPPING code
|
||||
// defaults (the balance-retune values in DomainConfig.cs are those defaults), so `new HandicapConfig()` etc. is exactly the
|
||||
// production config — the live OP testing JSON is deliberately NOT involved (balance source of truth = code defaults).
|
||||
internal static class T
|
||||
{
|
||||
public const int BaseMaxHp = 100; // SnapshotBuilder.BaseMaxHp
|
||||
public const int BaseMaxArmor = 100; // SnapshotBuilder.BaseMaxArmor
|
||||
|
||||
public static HandicapConfig Hcap() => new();
|
||||
public static ProgressionConfig Prog() => new();
|
||||
public static StatsConfig Stats() => new();
|
||||
public static SurvivalConfig Surv() => new();
|
||||
public static AbilitiesConfig Abil() => new();
|
||||
|
||||
public static ResolvedHandicap Resolved() => new(Hcap());
|
||||
public static ResolvedHandicap Resolved(HandicapConfig h) => new(h);
|
||||
|
||||
// Mirrors SnapshotBuilder.RebuildStatDefs + Stats.StatRegistry EXACTLY (same 12 key->StatDef rows, StringComparer.Ordinal).
|
||||
// If StatRegistry changes, this must change with it — the load-time validators in the engine keep the live registry honest;
|
||||
// this is the test-side twin.
|
||||
public static FrozenDictionary<string, StatDef> StatDefs() => StatDefs(Stats());
|
||||
public static FrozenDictionary<string, StatDef> StatDefs(StatsConfig c) => new Dictionary<string, StatDef>(StringComparer.Ordinal)
|
||||
{
|
||||
[StatKeys.Damage] = c.Damage,
|
||||
[StatKeys.CritChance] = c.CritChance,
|
||||
[StatKeys.CritDamage] = c.CritDamage,
|
||||
[StatKeys.HeadshotDamage] = c.HeadshotDamage,
|
||||
[StatKeys.MaxHp] = c.MaxHp,
|
||||
[StatKeys.MaxArmor] = c.MaxArmor,
|
||||
[StatKeys.Lifesteal] = c.Lifesteal,
|
||||
[StatKeys.ArmorLifesteal] = c.ArmorLifesteal,
|
||||
[StatKeys.HpRegen] = c.HpRegen,
|
||||
[StatKeys.ArmorRegen] = c.ArmorRegen,
|
||||
[StatKeys.Thorns] = c.Thorns,
|
||||
[StatKeys.XpBoost] = c.XpBoost,
|
||||
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
|
||||
// A PlayerSnapshot factory: neutral by default (kills+deaths<3 so the K/D factor is dormant, full HP, no floor/team buff,
|
||||
// no abilities). Override only the fields a case cares about.
|
||||
public static PlayerSnapshot Snap(
|
||||
int level = 1, int prestige = 0, long xp = 0,
|
||||
int kills = 0, int deaths = 0, int headshotKills = 0, int streak = 0,
|
||||
int health = 100,
|
||||
double progress = 0.0, double floor = -1.0,
|
||||
double teamDeal = 1.0, double teamTake = 1.0,
|
||||
bool overcharge = false, bool berserk = false, bool adrenaline = false,
|
||||
Dictionary<string, int>? upgrades = null, IStatBonusSource? cards = null) => new()
|
||||
{
|
||||
Level = level,
|
||||
Prestige = prestige,
|
||||
Xp = xp,
|
||||
Kills = kills,
|
||||
Deaths = deaths,
|
||||
HeadshotKills = headshotKills,
|
||||
Streak = streak,
|
||||
Health = health,
|
||||
HandicapProgress = progress,
|
||||
HandicapFloor = floor,
|
||||
TeamDealMult = teamDeal,
|
||||
TeamTakeMult = teamTake,
|
||||
OverchargeActive = overcharge,
|
||||
BerserkActive = berserk,
|
||||
AdrenalineActive = adrenaline,
|
||||
Upgrades = upgrades ?? new Dictionary<string, int>(),
|
||||
Cards = cards,
|
||||
};
|
||||
|
||||
// Relative-tolerance double compare. The fractional-t bands route through Math.Pow, whose last ULP can differ from the
|
||||
// independent Python oracle that produced the literals; 1e-9 is microscopic next to any real formula change (which moves a
|
||||
// value by whole percent), so this still catches every regression while ignoring FP noise.
|
||||
public static void Close(double expected, double actual, double rel = 1e-9)
|
||||
{
|
||||
double tol = rel * Math.Max(1.0, Math.Abs(expected));
|
||||
Assert.True(Math.Abs(expected - actual) <= tol,
|
||||
$"expected {expected:R}, got {actual:R} (|diff|={Math.Abs(expected - actual):R} > tol={tol:R})");
|
||||
}
|
||||
}
|
||||
|
||||
// Dictionary-backed IStatBonusSource — stands in for the survival run's per-stat card accumulator. The Domain only ever calls
|
||||
// Bonus(key); the engine-side level*PerPick accumulation isn't Domain logic, so a flat dictionary is the faithful test double.
|
||||
internal sealed class Cards(params (string key, double bonus)[] entries) : IStatBonusSource
|
||||
{
|
||||
private readonly Dictionary<string, double> _b = entries.ToDictionary(e => e.key, e => e.bonus, StringComparer.Ordinal);
|
||||
|
||||
public double Bonus(string statKey) => _b.TryGetValue(statKey, out var v) ? v : 0.0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue