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