initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
102
Outnumbered/Domain/HandicapModel.cs
Normal file
102
Outnumbered/Domain/HandicapModel.cs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue