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,102 @@
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
}