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

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

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
}

View 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
}

View 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);
}

View 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";
}

View 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);
}

View 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);
}