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 StatDefs() => StatDefs(Stats()); public static FrozenDictionary StatDefs(StatsConfig c) => new Dictionary(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? 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(), 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 _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; }