using Outnumbered.Config; namespace Outnumbered.Domain; // The balance spine. A single signed index t in [-1,+1] from four normalised factors (K/D, headshot-kill rate, // killstreak, level) plus the mode progress axis, then raised by the mode floor and eased by Curve. Deal/take/XP all // lerp from the same t so they reach their extremes together: // t = +1 -> deal MDealFloor, take MTakeCeiling, XP XpCeiling (a dominant player) // t = 0 -> x1 across the board // t = -1 -> deal MDealCeiling, take MTakeFloor, XP XpFloor (a struggling player) // t = MasterDifficulty*(N - B): N = weighted nerf average, B = how far K/D sits below neutral (comeback help). public static class HandicapModel { // The four nerf factors (K/D, headshot-rate, streak, level) + the mode progress axis are kept INLINE rather than a // HandicapFactor registry: adding a factor is rare and would need a new weight in HandicapConfig + its override mirror // regardless, so a list buys little. The config-invariant guard denominators + weight-sum are precomputed once per // reload in ResolvedHandicap (below) so this per-hit/per-tick kernel doesn't redo 5 Math.Max + 4 adds each call. public static double ComputeT(in PlayerSnapshot s, in ResolvedHandicap rh) { var h = rh.Config; if (!h.Enabled) return 0.0; double kdN = 0.0, buff = 0.0; if (s.Kills + s.Deaths >= 3) { double r = s.Kills / (double)Math.Max(1, s.Deaths); kdN = Clamp01((r - h.KdNeutral) / rh.KdNerfDenom); buff = Clamp01((h.KdNeutral - r) / rh.KdBuffDenom); } double hsRate = s.Kills >= h.HsMinKills ? s.HeadshotKills / (double)Math.Max(1, s.Kills) : 0.0; double hsN = Clamp01(hsRate / rh.HsDenom); double streakN = Clamp01(s.Streak / rh.StreakDenom); double levelN = Clamp01(s.Level / rh.LevelDenom); double progN = Clamp01(s.HandicapProgress); double n = rh.WSum <= 0 ? 0.0 : (h.KdWeight * kdN + h.HsWeight * hsN + h.StreakWeight * streakN + h.LevelWeight * levelN + h.ProgressWeight * progN) / rh.WSum; double t = Math.Clamp(h.MasterDifficulty * (n - buff), -1.0, 1.0); if (s.HandicapFloor > t) t = Math.Min(s.HandicapFloor, 1.0); // monotonic floor: never eases below the mode's escalation return Ease(t, h.Curve); } // The Curve easing, shared by the live path (ComputeT) and the API curve sampler (BandsFromT) — one definition, no drift. private static double Ease(double t, double curve) => t >= 0 ? Math.Pow(t, curve) : -Math.Pow(-t, curve); // The three bands as pure functions of an already-computed t (so callers that need several can share ONE ComputeT). private static double DealFromT(double t, HandicapConfig h, double teamDeal) => (t >= 0 ? Lerp(1.0, h.MDealFloor, t) : Lerp(1.0, h.MDealCeiling, -t)) * teamDeal; private static double TakeFromT(double t, HandicapConfig h, double teamTake) => (t >= 0 ? Lerp(1.0, h.MTakeCeiling, t) : Lerp(1.0, h.MTakeFloor, -t)) * teamTake; private static double XpFromT(double t, HandicapConfig h) => t >= 0 ? Lerp(1.0, h.XpCeiling, t) : Lerp(1.0, h.XpFloor, -t); // Damage the player DEALS (x1 neutral). The survival team buff rides on top so it reaches every survivor + path. public static double MDeal(in PlayerSnapshot s, in ResolvedHandicap rh) => DealFromT(ComputeT(s, rh), rh.Config, s.TeamDealMult); // Damage the player TAKES (x1 neutral). The survival team buff multiplies it down for every survivor. public static double MTake(in PlayerSnapshot s, in ResolvedHandicap rh) => TakeFromT(ComputeT(s, rh), rh.Config, s.TeamTakeMult); public static double XpMult(in PlayerSnapshot s, in ResolvedHandicap rh) => XpFromT(ComputeT(s, rh), rh.Config); // Deal + take + XP from ONE ComputeT — for the HUD, which shows all three per player per tick (the per-hit damage // hook needs only one band, so it keeps calling MDeal/MTake directly). Bit-identical to calling all three. public static void Bands(in PlayerSnapshot s, in ResolvedHandicap rh, out double deal, out double take, out double xp) { double t = ComputeT(s, rh); deal = DealFromT(t, rh.Config, s.TeamDealMult); take = TakeFromT(t, rh.Config, s.TeamTakeMult); xp = XpFromT(t, rh.Config); } // Neutral curve sample for a RAW index t in [-1,+1] (the ease is applied here; team multipliers stay 1): the balance // API serializes dense arrays of these, so the site's theorycrafting curves come from the SAME compiled band code // that scales live damage — they cannot drift from what players experience. public static void BandsFromT(double t, in ResolvedHandicap rh, out double deal, out double take, out double xp) { // Same short-circuit as ComputeT: a disabled handicap IS flat x1 — sampled curves must say so too. if (!rh.Config.Enabled) { deal = 1.0; take = 1.0; xp = 1.0; return; } double e = Ease(Math.Clamp(t, -1.0, 1.0), rh.Config.Curve); deal = DealFromT(e, rh.Config, 1.0); take = TakeFromT(e, rh.Config, 1.0); xp = XpFromT(e, rh.Config); } private static double Clamp01(double v) => v < 0 ? 0 : v > 1 ? 1 : v; private static double Lerp(double a, double b, double t) => a + (b - a) * t; } // HandicapConfig + its config-invariant guard denominators / weight-sum, precomputed ONCE per reload so ComputeT — // reached per damage hit + per HUD tick — doesn't redo 5 Math.Max + 4 adds each call. Two load-bearing constraints: // - Keep `n = numerator / WSum` a DIVIDE, never `numerator * (1/WSum)` — the reciprocal differs in the last ULP. // - StreakDenom/LevelDenom hold the EXACT (double)Math.Max(1, intField) value (int->double is exact here). public readonly struct ResolvedHandicap(HandicapConfig c) { public readonly HandicapConfig Config = c; public readonly double KdNerfDenom = Math.Max(0.01, c.KdMaxNerf - c.KdNeutral); // Max(0.01, KdMaxNerf - KdNeutral) public readonly double KdBuffDenom = Math.Max(0.01, c.KdNeutral - c.KdMinBuff); // Max(0.01, KdNeutral - KdMinBuff) public readonly double HsDenom = Math.Max(0.01, c.HsMaxNerf); // Max(0.01, HsMaxNerf) public readonly double StreakDenom = Math.Max(1, c.StreakMaxNerf); // Max(1, StreakMaxNerf) public readonly double LevelDenom = Math.Max(1, c.LevelMaxNerf); // Max(1, LevelMaxNerf) public readonly double WSum = c.KdWeight + c.HsWeight + c.StreakWeight + c.LevelWeight + c.ProgressWeight; // KdWeight + HsWeight + StreakWeight + LevelWeight + ProgressWeight }