93 lines
4.6 KiB
C#
93 lines
4.6 KiB
C#
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;
|
|
}
|