initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
16
Outnumbered/Domain/CardKeys.cs
Normal file
16
Outnumbered/Domain/CardKeys.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace Outnumbered;
|
||||
|
||||
// Keys for the survival "effect" cards — distinct from StatKeys so EffRun never folds them into a stat; only the effect
|
||||
// logic reads them (per-player via the run's IStatBonusSource, or as the two TEAM cards via the driver's team
|
||||
// multipliers). CSSharp-free so Domain + the test project can reference them without the engine.
|
||||
public static class CardKeys
|
||||
{
|
||||
public const string ExplodeKill = "explode_kill"; // 1-pick flag: HE blast on every bot-kill (chains)
|
||||
public const string Burn = "burn"; // 1-pick flag: on-hit DoT (flat, armor-skipping, per-attacker)
|
||||
public const string AbilityCdr = "ability_cdr"; // -% killstreak-ability cooldown
|
||||
public const string XpMult = "xp_mult"; // +% run-XP earned
|
||||
public const string HsReduction = "hs_reduction"; // -% incoming headshot damage (per-player, leveled)
|
||||
public const string BerserkPassive = "berserk_passive"; // +dmg scaled by missing HP (per-player, leveled)
|
||||
public const string GlobalDeal = "global_deal"; // TEAM: +dmg dealt, squad-wide, compounding (into MDeal)
|
||||
public const string GlobalTake = "global_take"; // TEAM: -dmg taken, squad-wide, compounding (into MTake)
|
||||
}
|
||||
78
Outnumbered/Domain/CombatResolver.cs
Normal file
78
Outnumbered/Domain/CombatResolver.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System.Collections.Frozen;
|
||||
using Outnumbered.Config;
|
||||
|
||||
namespace Outnumbered.Domain;
|
||||
|
||||
// The offense + defense multiplier chains — the SINGLE source of truth shared by the damage hook (applies them) and the
|
||||
// HUD readout (shows them). Pure: the crit decision (which has engine side effects: a sound + the crit-XP marker) is made
|
||||
// by the caller and passed in as `crit`; the HUD passes crit:false. Lifesteal/thorns amounts are here too so the reactive
|
||||
// sustain glue applies identical numbers.
|
||||
public static class CombatResolver
|
||||
{
|
||||
public static double CritChance(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs) =>
|
||||
StatResolver.EffRun(s, StatKeys.CritChance, defs);
|
||||
|
||||
// The damage hook path: computes its own MDeal (one ComputeT per hit). Delegates to the mDeal-taking overload.
|
||||
public static double OffenseMultiplier(in PlayerSnapshot s, bool headshot, bool crit,
|
||||
FrozenDictionary<string, StatDef> defs, in ResolvedHandicap rh, AbilitiesConfig ab, int baseMaxHp) =>
|
||||
OffenseMultiplier(s, headshot, crit, defs, ab, baseMaxHp, HandicapModel.MDeal(s, rh));
|
||||
|
||||
// Overload taking a PRECOMPUTED MDeal — lets the HUD share ONE ComputeT across deal/take/xp (via HandicapModel.Bands).
|
||||
// The missing-HP fraction (an EffRun lookup + a divide) is computed only when a Berserk consumer is actually active.
|
||||
public static double OffenseMultiplier(in PlayerSnapshot s, bool headshot, bool crit,
|
||||
FrozenDictionary<string, StatDef> defs, AbilitiesConfig ab, int baseMaxHp, double mDeal)
|
||||
{
|
||||
double dmgMul = 1.0 + StatResolver.EffRun(s, StatKeys.Damage, defs) / 100.0;
|
||||
double hsMul = headshot ? 1.0 + StatResolver.EffRun(s, StatKeys.HeadshotDamage, defs) / 100.0 : 1.0;
|
||||
double critMul = crit ? 1.0 + StatResolver.EffRun(s, StatKeys.CritDamage, defs) / 100.0 : 1.0;
|
||||
|
||||
double abilityMul = 1.0;
|
||||
double berserkCard = StatResolver.CardMag(s, CardKeys.BerserkPassive); // always-on passive card
|
||||
bool needMissing = berserkCard > 0 || (ab.Enabled && s.BerserkActive); // the only consumers of `missing`
|
||||
double missing = needMissing ? StatResolver.MissingHpFraction(s, StatResolver.MaxHp(s, defs, baseMaxHp)) : 0.0;
|
||||
if (ab.Enabled)
|
||||
{
|
||||
if (s.OverchargeActive) abilityMul *= 1.0 + ab.Overcharge.Magnitude / 100.0;
|
||||
if (s.BerserkActive)
|
||||
{
|
||||
abilityMul *= 1.0 + missing * ab.Berserk.Magnitude;
|
||||
if (crit) critMul += missing * ab.Berserk.Magnitude2;
|
||||
}
|
||||
}
|
||||
if (berserkCard > 0) abilityMul *= 1.0 + missing * berserkCard / 100.0;
|
||||
|
||||
return dmgMul * hsMul * critMul * abilityMul * mDeal;
|
||||
}
|
||||
|
||||
// The damage hook path: computes its own MTake. Delegates to the mTake-taking overload.
|
||||
public static double DefenseMultiplier(in PlayerSnapshot s, bool headshot, in ResolvedHandicap rh, AbilitiesConfig ab) =>
|
||||
DefenseMultiplier(s, headshot, ab, HandicapModel.MTake(s, rh));
|
||||
|
||||
// Overload taking a PRECOMPUTED MTake (the global_take team card is already folded in by HandicapModel).
|
||||
public static double DefenseMultiplier(in PlayerSnapshot s, bool headshot, AbilitiesConfig ab, double mTake)
|
||||
{
|
||||
double take = mTake;
|
||||
if (ab.Enabled && s.AdrenalineActive) take *= 1.0 - ab.Adrenaline.Magnitude / 100.0;
|
||||
if (headshot)
|
||||
{
|
||||
double hsCut = StatResolver.CardMag(s, CardKeys.HsReduction); // armor doesn't help vs HS; this does
|
||||
if (hsCut > 0) take *= Math.Max(0.0, 1.0 - hsCut / 100.0);
|
||||
}
|
||||
return take;
|
||||
}
|
||||
|
||||
// ---- pure amounts the reactive-sustain glue applies (it still does the engine HP/armor writes) ----
|
||||
// dmgHealth is a double so BOTH callers share one formula bit-identically: the on-hit path passes the int
|
||||
// ev.DmgHealth (widens exactly) with the crit multiplier; the thorns-reflect path passes the FLOAT `dealt`
|
||||
// (the offense-scaled reflected damage — keeps full precision, no pre-truncation) with critMult=1.0 (×1.0 is exact).
|
||||
public static int LifestealHeal(double dmgHealth, double lifestealPct, double critMult, int minHeal) =>
|
||||
Math.Max(minHeal, (int)Math.Round(dmgHealth * lifestealPct / 100.0 * critMult));
|
||||
|
||||
public static int ArmorLifestealGain(double dmgHealth, double armorLifestealPct, double critMult) =>
|
||||
(int)Math.Round(dmgHealth * armorLifestealPct / 100.0 * critMult);
|
||||
|
||||
// % of the damage ACTUALLY taken (health + armor, so it already includes the MTake handicap that sized the hit — a 5x
|
||||
// handicap 250 HP hit at 10% is 25). The caller deals this FLAT (DamageDealer flat) so the bot eats exactly this: no
|
||||
// build and no MDeal are re-applied on the way out.
|
||||
public static double ThornsReflect(int dmgHealth, int dmgArmor, double thornsPct) => (dmgHealth + dmgArmor) * thornsPct / 100.0;
|
||||
}
|
||||
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
|
||||
}
|
||||
37
Outnumbered/Domain/PlayerSnapshot.cs
Normal file
37
Outnumbered/Domain/PlayerSnapshot.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
namespace Outnumbered.Domain;
|
||||
|
||||
// Per-stat run-scoped bonus (the survival cards). Implemented engine-side by the active driver; the domain only sees
|
||||
// this interface, so it stays pure. Returns 0 for any key without a bonus (and outside a survival run).
|
||||
public interface IStatBonusSource
|
||||
{
|
||||
double Bonus(string statKey);
|
||||
}
|
||||
|
||||
// An immutable read of everything the pure domain math needs about one player, built at the single engine site
|
||||
// (SnapshotBuilder). The domain never touches a pawn/controller — only this. Upgrades is held by reference (not copied)
|
||||
// so building a snapshot per hit/tick stays allocation-free; the dictionary is treated as read-only here.
|
||||
public readonly struct PlayerSnapshot
|
||||
{
|
||||
public int Level { get; init; }
|
||||
public int Prestige { get; init; }
|
||||
public long Xp { get; init; }
|
||||
|
||||
public int Kills { get; init; }
|
||||
public int Deaths { get; init; }
|
||||
public int HeadshotKills { get; init; }
|
||||
public int Streak { get; init; }
|
||||
|
||||
public int Health { get; init; } // current HP (0 = no live pawn captured) — drives missing-HP (Berserk)
|
||||
|
||||
public double HandicapProgress { get; init; } // mode progress axis, 0..1 (Gun Game ladder; 0 in TDM)
|
||||
public double HandicapFloor { get; init; } // monotonic escalation floor in t-space; -1 = no floor
|
||||
public double TeamDealMult { get; init; } // survival team card (squad-wide); 1.0 = none
|
||||
public double TeamTakeMult { get; init; } // survival team card (squad-wide); 1.0 = none
|
||||
|
||||
public bool OverchargeActive { get; init; } // killstreak abilities that bend the damage chains
|
||||
public bool BerserkActive { get; init; }
|
||||
public bool AdrenalineActive { get; init; }
|
||||
|
||||
public Dictionary<string, int> Upgrades { get; init; } // permanent stat levels, by key (the PlayerData instance, by ref)
|
||||
public IStatBonusSource? Cards { get; init; } // run-card bonus per stat key; null outside survival
|
||||
}
|
||||
16
Outnumbered/Domain/ProgressionModel.cs
Normal file
16
Outnumbered/Domain/ProgressionModel.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using Outnumbered.Config;
|
||||
|
||||
namespace Outnumbered.Domain;
|
||||
|
||||
// XP/level/prestige curves (pure). The stateful level-up loop + its side effects (chat/sound/clan) stay engine-side;
|
||||
// this owns only the formulas it walks.
|
||||
public static class ProgressionModel
|
||||
{
|
||||
// XP required to go from `level` to `level+1`. LevelXpExponent shapes the curve (1 = linear, >1 = accelerating).
|
||||
public static long XpToNext(int level, ProgressionConfig c) =>
|
||||
c.LevelXpBase + (long)Math.Round(c.LevelXpStep * Math.Pow(Math.Max(0, level - 1), c.LevelXpExponent));
|
||||
|
||||
// Cumulative prestige XP boost (prestige never lowers difficulty; it only speeds the climb).
|
||||
public static double PrestigeXpMultiplier(int prestige, ProgressionConfig c) =>
|
||||
1.0 + prestige * (c.PrestigeXpBoostPercent / 100.0);
|
||||
}
|
||||
20
Outnumbered/Domain/StatKeys.cs
Normal file
20
Outnumbered/Domain/StatKeys.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
namespace Outnumbered;
|
||||
|
||||
// Canonical stat keys (upgrade dictionary keys + JSON stat ids). CSSharp-free so the Domain layer + the test project
|
||||
// can reference them without the engine. Kept in the root Outnumbered namespace (not Domain) for back-compat with the
|
||||
// many engine-side call sites that use the unqualified name.
|
||||
public static class StatKeys
|
||||
{
|
||||
public const string Damage = "damage";
|
||||
public const string CritChance = "crit_chance";
|
||||
public const string CritDamage = "crit_damage";
|
||||
public const string HeadshotDamage = "hs_damage";
|
||||
public const string MaxHp = "max_hp";
|
||||
public const string MaxArmor = "max_armor";
|
||||
public const string Lifesteal = "lifesteal";
|
||||
public const string ArmorLifesteal = "armor_lifesteal";
|
||||
public const string HpRegen = "hp_regen";
|
||||
public const string ArmorRegen = "armor_regen";
|
||||
public const string Thorns = "thorns";
|
||||
public const string XpBoost = "xp_boost";
|
||||
}
|
||||
41
Outnumbered/Domain/StatResolver.cs
Normal file
41
Outnumbered/Domain/StatResolver.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Collections.Frozen;
|
||||
using Outnumbered.Config;
|
||||
|
||||
namespace Outnumbered.Domain;
|
||||
|
||||
// Resolves a player's effective stat values from their invested levels + run-card bonuses. Takes a stat-def lookup
|
||||
// (key -> StatDef) rather than the named-field config, so it's already shaped for the stat registry — the engine builds
|
||||
// the dictionary once and passes it. `Eff` = permanent only; `EffRun` = permanent + survival card; `CardMag` = the
|
||||
// effect-card magnitude (a non-stat key, cards only).
|
||||
public static class StatResolver
|
||||
{
|
||||
private static readonly StatDef Zero = new(0, 0);
|
||||
|
||||
private static int LevelOf(in PlayerSnapshot s, string key) =>
|
||||
s.Upgrades is not null && s.Upgrades.TryGetValue(key, out var l) ? l : 0;
|
||||
|
||||
public static double Eff(in PlayerSnapshot s, string key, FrozenDictionary<string, StatDef> defs)
|
||||
{
|
||||
var d = defs.TryGetValue(key, out var def) ? def : Zero;
|
||||
return d.Base + LevelOf(s, key) * d.PerLevel;
|
||||
}
|
||||
|
||||
public static double EffRun(in PlayerSnapshot s, string key, FrozenDictionary<string, StatDef> defs) =>
|
||||
Eff(s, key, defs) + (s.Cards?.Bonus(key) ?? 0.0);
|
||||
|
||||
public static double CardMag(in PlayerSnapshot s, string key) => s.Cards?.Bonus(key) ?? 0.0;
|
||||
|
||||
public static int MaxHp(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs, int baseMax) =>
|
||||
baseMax + (int)EffRun(s, StatKeys.MaxHp, defs);
|
||||
|
||||
public static int MaxArmor(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs, int baseMax) =>
|
||||
baseMax + (int)EffRun(s, StatKeys.MaxArmor, defs);
|
||||
|
||||
// 0 at full HP, 1 near death — drives Berserk (ability + passive card). Health<=0 (a snapshot built with no live
|
||||
// pawn, or a downed pawn) yields 0, NOT 1: missing-HP scaling only applies to a live attacker, so "no pawn" must
|
||||
// mean "no bonus", not "max bonus" (guards the pd-less Snapshot path where Health defaults to 0).
|
||||
public static double MissingHpFraction(in PlayerSnapshot s, int maxHp) =>
|
||||
// Health>=1 (guarded) and maxHp>=1 => 1 - Health/maxHp < 1, so the upper clamp can't bind; only the lower
|
||||
// (overheal -> negative) is live. Math.Max(0, x) is bit-identical to Math.Clamp(x, 0, 1) here.
|
||||
maxHp <= 0 || s.Health <= 0 ? 0.0 : Math.Max(0.0, 1.0 - s.Health / (double)maxHp);
|
||||
}
|
||||
48
Outnumbered/Domain/SurvivalEconomy.cs
Normal file
48
Outnumbered/Domain/SurvivalEconomy.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using Outnumbered.Config;
|
||||
|
||||
namespace Outnumbered.Domain;
|
||||
|
||||
// Pure survival run-economy + escalation curves. The wave state machine + run banking stay engine-side; this owns the
|
||||
// numbers they read.
|
||||
public static class SurvivalEconomy
|
||||
{
|
||||
// Bots ALIVE at once this wave (simultaneous pressure), ramping from AliveBase to AliveCap.
|
||||
public static int AliveForWave(int wave, SurvivalConfig c) =>
|
||||
Math.Clamp(c.AliveBase + c.AlivePerWave * (wave - 1), 1, c.AliveCap);
|
||||
|
||||
// Total kills to clear a wave.
|
||||
public static int WaveBudget(int wave, int aliveHumans, SurvivalConfig c) =>
|
||||
Math.Max(1, c.BudgetBase + c.BudgetPerWave * (wave - 1) + c.BudgetPerPlayer * aliveHumans);
|
||||
|
||||
// Monotonic escalate-only handicap floor in t-space; -1 = no floor (idle / between runs).
|
||||
public static double HandicapFloor(int wave, SurvivalConfig c) =>
|
||||
// wave>=1 and Max(1,MaxNerfWave)>=1 => quotient>=0, so the lower clamp can never bind; Min(1,x) == Clamp(x,0,1) here.
|
||||
wave <= 0 ? -1.0 : Math.Min(1.0, wave / (double)Math.Max(1, c.MaxNerfWave));
|
||||
|
||||
// Per-wave XP multiplier: ramps exponentially from x1 (wave 1) to xWinMult (the final wave). WinMult is the single
|
||||
// knob (the FINAL-wave / "win" multiplier). Back-loaded by design so early waves pay ~nothing and the last few pay big.
|
||||
// waveMult(w) = WinMult ^ ((w-1)/(WaveCount-1))
|
||||
public static double WaveMult(int wave, SurvivalConfig c) =>
|
||||
Math.Pow(c.WinMult, (wave - 1) / (double)Math.Max(1, c.WaveCount - 1));
|
||||
|
||||
// XP granted at the END of ONE cleared wave: raw wave XP (HP-damage + HS/crit, already xp_mult-card-scaled when banked)
|
||||
// x prestige x the wave multiplier. NO per-run cap, NO XpBoost stat, NO handicap mult here — depth is the gate (you must
|
||||
// survive + contribute to reach the big back-wave multipliers), and the handicap mult stays excluded so run XP can't
|
||||
// re-couple to gameable K/D. 0 for a non-positive wave.
|
||||
public static long WaveXpLump(double rawWaveXp, int wave, int prestige, SurvivalConfig c, ProgressionConfig p)
|
||||
{
|
||||
if (rawWaveXp <= 0) return 0;
|
||||
double lump = rawWaveXp * ProgressionModel.PrestigeXpMultiplier(prestige, p) * WaveMult(wave, c);
|
||||
return (long)Math.Floor(lump);
|
||||
}
|
||||
|
||||
// Raw combat XP banked into the CURRENT wave's accumulator, scaled by the xp_mult card (so it compounds with the
|
||||
// per-wave prestige x waveMult chain applied at grant time).
|
||||
public static double AccrueWaveXp(double amount, double xpMultCardPct) =>
|
||||
amount * (1.0 + xpMultCardPct / 100.0);
|
||||
|
||||
// Team-card squad multiplier (compounding): global_deal increases, global_take decreases.
|
||||
public static double TeamMult(int level, double perPickPct, bool increase) =>
|
||||
increase ? Math.Pow(1.0 + perPickPct / 100.0, level)
|
||||
: Math.Pow(Math.Max(0.0, 1.0 - perPickPct / 100.0), level);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue