cs2-outnumbered/Outnumbered/Stats.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

300 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Domain;
using Outnumbered.Engine;
namespace Outnumbered;
// The stat core. Offense (Damage/Crit/Headshot) goes through the central damage hook; reactive sustain (lifesteal) on
// EventPlayerHurt; HP/Armor caps per spawn; regen on a timer. All values come from the player's upgrade levels.
// (StatKeys lives in Domain/StatKeys.cs — CSSharp-free + test-shared.)
public sealed partial class OutnumberedPlugin
{
// The passive-stat REGISTRY — one row per stat, the single place a stat's identity lives: display name, the shop SKILL
// GROUP it sits in (0/1/2), and a selector picking its LIVE StatDef out of Config.Stats (so !og_reload tunables apply).
// StatList, the _statDefs key->def registry (SnapshotBuilder.RebuildStatDefs), and SkillGroupStats all PROJECT from this,
// so adding/reordering a stat is one row here (+ a StatKeys const + the named Config.Stats field kept for JSON back-compat).
// Mirrors AbilityRegistry/AbilityInfo.Def. Order = StatList order; within a group = shop key order.
internal sealed record StatInfo(string Key, string Display, int Group, Func<StatsConfig, StatDef> Def);
internal static readonly StatInfo[] StatRegistry =
[
new(StatKeys.Damage, "Damage", 0, c => c.Damage),
new(StatKeys.CritChance, "Crit Chance", 0, c => c.CritChance),
new(StatKeys.CritDamage, "Crit Damage", 0, c => c.CritDamage),
new(StatKeys.HeadshotDamage, "Headshot Damage", 0, c => c.HeadshotDamage),
new(StatKeys.MaxHp, "Max HP", 1, c => c.MaxHp),
new(StatKeys.MaxArmor, "Max Armor", 1, c => c.MaxArmor),
new(StatKeys.Lifesteal, "Lifesteal", 2, c => c.Lifesteal),
new(StatKeys.ArmorLifesteal, "Armor Lifesteal", 2, c => c.ArmorLifesteal),
new(StatKeys.HpRegen, "HP Regen", 1, c => c.HpRegen),
new(StatKeys.ArmorRegen, "Armor Regen", 1, c => c.ArmorRegen),
new(StatKeys.Thorns, "Thorns", 2, c => c.Thorns),
new(StatKeys.XpBoost, "XP Boost", 2, c => c.XpBoost),
];
// key -> display, projected from the registry (UI + the load-time validation read it). Order = StatRegistry order.
private static readonly (string Key, string Display)[] StatList =
StatRegistry.Select(r => (r.Key, r.Display)).ToArray();
// Shop skill groups (each group rendered on the grenade-key tail). Grouped from the registry in group-index order,
// within-group in registry order — lives here (the stat hub), the canonical stat grouping, not in Shop.cs.
private static readonly string[][] SkillGroupStats =
StatRegistry.GroupBy(r => r.Group).OrderBy(g => g.Key).Select(g => g.Select(r => r.Key).ToArray()).ToArray();
private const int HitgroupHead = 1; // BaseMaxHp/BaseMaxArmor live in SnapshotBuilder.cs (same partial class)
private const double ThornsReflectCapHp = 25.0; // hard, UNCONFIGURABLE cap on a single thorns reflect — a high thorns % can't trivialize waves
private CounterStrikeSharp.API.Modules.Timers.Timer? _regenTimer;
private readonly HashSet<ulong> _dmgReadout = new(); // !dmg — players seeing the per-hit final-damage readout
private readonly Dictionary<int, bool> _critPending = new(); // victim slot -> was this hit a crit (damage hook -> EventPlayerHurt, for crit XP)
private static string HitgroupName(int h) => h switch
{
1 => "HEAD",
2 => "chest",
3 => "stomach",
4 or 5 => "arm",
6 or 7 => "leg",
_ => "body",
};
private void Initialize_Stats()
{
RebuildStatDefs(); // build the key->StatDef registry the Domain resolvers index (rebuilt on !og_reload)
// StatList, _statDefs and SkillGroupStats all PROJECT from StatRegistry, so their agreement is guaranteed by
// construction. The one thing the registry can't prevent: a skill group with more stats than the shop can address
// (each group renders on the grenade-key tail), which would leave the extras unreachable in the menu.
foreach (var g in SkillGroupStats)
if (g.Length > GrenadeMenuKeys.Length)
Logger.LogError("Outnumbered: a SkillGroupStats group has {N} stats but only {K} shop keys are addressable — the extras can't be selected.", g.Length, GrenadeMenuKeys.Length);
// SkillGroupStats is derived from StatRegistry's distinct Group values; SkillGroupNames[i] labels group i. The one
// coupling the registry can't enforce: a new group (a stat with a higher Group index) needs a matching name, else
// the shop would index past SkillGroupNames when rendering that group's header.
if (SkillGroupStats.Length != SkillGroupNames.Length)
Logger.LogError("Outnumbered: {N} skill groups but {M} SkillGroupNames labels — add a name for each group.", SkillGroupStats.Length, SkillGroupNames.Length);
RegisterListener<Listeners.OnEntityTakeDamagePre>(OnEntityTakeDamagePre);
RegisterEventHandler<EventPlayerHurt>(OnPlayerHurt_Stats);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Stats);
_regenTimer = AddTimer(Config.Stats.RegenIntervalSeconds, RegenTick, TimerFlags.REPEAT);
}
private void Shutdown_Stats() => _regenTimer?.Kill();
// ---- stat-resolution bridge: pd -> Domain. The FORMULAS live in Outnumbered.Domain.StatResolver; these are
// zero-math accessors that snapshot the player and call it, so cold/UI/cross-file sites stay ergonomic. The hot
// damage hook + HUD skip these and call CombatResolver with ONE snapshot (the shared offense/defense chain). ----
private StatDef DefFor(string key) => _statDefs.TryGetValue(key, out var d) ? d : new StatDef(0, 0);
private static int LevelOf(PlayerData pd, string key) => pd.Upgrades.TryGetValue(key, out var l) ? l : 0;
private double Eff(PlayerData pd, string key) => StatResolver.Eff(Snapshot(pd), key, _statDefs);
// Magnitude of a survival EFFECT card (a CardKeys.* key, NOT a stat) = picks x PerPick via the driver; 0 outside a run.
// The engine/live-path accessor for the non-snapshot presence checks (burn/explode/cdr) — distinct from the
// snapshot-pure Domain StatResolver.CardMag used inside CombatResolver.
private double EffectCardMag(PlayerData pd, string key) => _driver?.StatBonus(pd, key) ?? 0.0;
private int MaxHpOf(PlayerData pd) => StatResolver.MaxHp(Snapshot(pd), _statDefs, BaseMaxHp);
private int MaxArmorOf(PlayerData pd) => StatResolver.MaxArmor(Snapshot(pd), _statDefs, BaseMaxArmor);
// Snapshot-level overloads: when a caller already built ONE snapshot (the hot reactive-sustain + regen paths), reuse it
// across several stat reads instead of the pd-overloads rebuilding Snapshot(pd) per accessor. Identical result (the
// pd-overloads just call these on a fresh Snapshot(pd)), computed once.
private double EffRun(in PlayerSnapshot s, string key) => StatResolver.EffRun(s, key, _statDefs);
private int MaxHpOf(in PlayerSnapshot s) => StatResolver.MaxHp(s, _statDefs, BaseMaxHp);
private int MaxArmorOf(in PlayerSnapshot s) => StatResolver.MaxArmor(s, _statDefs, BaseMaxArmor);
// ---- damage hook (offense + defense). Both multiplier chains come from the SHARED Domain CombatResolver (so the
// HUD readout can never drift from what actually applies); the hook keeps only the engine bits — pawn->controller
// resolution, the crit roll plus its side effects (sound + crit-XP marker), and writing info.Damage. ----
private HookResult OnEntityTakeDamagePre(CBaseEntity entity, CTakeDamageInfo info)
{
// An unscaled plugin hit is in flight (raw burn DoT, or a flat thorns reflect): apply the damage exactly as set —
// no offense scaling, no crit roll. Re-entrancy guard, reset in DamageDealer.Deal's finally.
if (DamageDealer.Unscaled) return HookResult.Continue;
// pawn designer-name varies by build ("cs_player_pawn" or "player") — catalog in EngineNames
if (entity is not { IsValid: true } || !EngineNames.PlayerPawnDesigners.Contains(entity.DesignerName))
return HookResult.Continue;
var attacker = ControllerOfPawn(info.Attacker.Value?.As<CCSPlayerPawn>());
var victim = ControllerOfPawn(new CCSPlayerPawn(entity.Handle));
// OFFENSE — a human dealing damage: build x crit x abilities x M_deal (the whole chain in CombatResolver).
if (attacker is { IsValid: true } && !attacker.IsBot)
{
var asid = attacker.AuthorizedSteamID?.SteamId64;
if (asid is not null && _players.TryGetValue(asid.Value, out var apd))
{
// GG speedrun clock: arms on the run's FIRST damage dealt-or-taken (self-damage included — arming early
// can only lengthen your time, so it's abuse-proof). Hot path: one bool + one field check, nothing else.
if (_ggTimerActive && apd.GgRunStartedAtMs == 0)
apd.GgRunStartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (victim is { IsValid: true } && victim.Slot == attacker.Slot) return HookResult.Continue; // no self-scaling
bool headshot = (int)info.GetHitGroup() == HitgroupHead; // pre-engine-HS; engine applies its ~4x afterward
var s = Snapshot(apd, attacker);
double critChance = CombatResolver.CritChance(s, _statDefs);
bool crit = critChance > 0 && Random.Shared.NextDouble() * 100.0 < critChance;
if (crit) PlaySound(attacker, Config.Sounds.Crit); // no-op unless a Crit sound is configured
if (victim is { IsValid: true }) _critPending[victim.Slot] = crit; // remembered for crit XP in EventPlayerHurt
info.Damage = (float)(info.Damage *
CombatResolver.OffenseMultiplier(s, headshot, crit, _statDefs, _hcap, Config.Abilities, BaseMaxHp));
return HookResult.Continue;
}
}
// DEFENSE — a human taking damage (from a bot/world): x M_take x Adrenaline x headshot-armor card.
if (victim is { IsValid: true } && !victim.IsBot)
{
var vsid = victim.AuthorizedSteamID?.SteamId64;
if (vsid is not null && _players.TryGetValue(vsid.Value, out var vpd))
{
// GG speedrun clock, taken-damage side (covers bot AND world/fall damage — the attacker branch never sees those).
if (_ggTimerActive && vpd.GgRunStartedAtMs == 0)
vpd.GgRunStartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool headshot = (int)info.GetHitGroup() == HitgroupHead;
info.Damage = (float)(info.Damage *
CombatResolver.DefenseMultiplier(Snapshot(vpd, victim), headshot, _hcap, Config.Abilities));
}
}
return HookResult.Continue;
}
// ---- reactive sustain + last-damage tracking ----
private HookResult OnPlayerHurt_Stats(EventPlayerHurt ev, GameEventInfo info)
{
var victim = ev.Userid;
var attacker = ev.Attacker;
// THORNS — a human hit by a bot reflects a % of the damage taken back onto that bot. The reflect is dealt as
// real, attributed damage (so it can kill and credits the player for the kill/XP/GG-rung) and re-enters the
// offense hook in OnEntityTakeDamagePre, so it scales with the player's damage build AND the handicap
// (a nerfed steamroller's thorns are nerfed too — no bypass; a buffed weak player's thorns hit harder).
if (victim is { IsValid: true } && !victim.IsBot
&& attacker is { IsValid: true } && attacker.IsBot && attacker.Slot != victim.Slot
&& PdOf(victim) is { } tpd)
{
var st = Snapshot(tpd); // ONE snapshot: thorns % + both steal reads
double thorns = EffRun(st, StatKeys.Thorns);
// Thorns is OFF while the reflector holds a knife or the zeus: a GG knife/zeus finale must be won by a REAL melee
// kill, not by tanking hits and letting the reflect kill the bot. (The active-weapon probe runs only once thorns is
// actually in play — the cheap thorns/damage checks short-circuit first.)
if (thorns > 0 && ev.DmgHealth + ev.DmgArmor > 0
&& !Inventory.IsMeleeOrZeus(Inventory.ActiveWeaponName(victim.PlayerPawn.Value)))
{
// Thorns reflects a straight % of the damage ACTUALLY taken (already includes the MTake handicap that sized the
// hit — a 5x-handicap 250 HP hit at 10% sends 25 back), dealt FLAT so the offense hook neither scales it by the
// build nor lets the handicap nerf it. Clamp to [1, ThornsReflectCapHp]: floor so a tiny hit still reflects,
// hard cap so a high thorns % can't trivialize waves.
double reflect = Math.Clamp(CombatResolver.ThornsReflect(ev.DmgHealth, ev.DmgArmor, thorns), 1.0, ThornsReflectCapHp);
float dealt = DamageDealer.Deal(attacker, victim, (float)reflect, flat: true); // target = the bot, source = the human
// The TakeDamageOld invoke doesn't raise player_hurt, so the lifesteal block below never sees the reflected
// hit — apply the reflector's lifesteal/armor-lifesteal here, on the actual damage dealt.
if (dealt > 0 && victim.PlayerPawn.Value is { } vpawn)
{
// No crit on the reflect's steal, so critMult=1.0 — same formula as the on-hit path (CombatResolver).
double ls = EffRun(st, StatKeys.Lifesteal);
if (ls > 0) PawnWriter.AddHealthCapped(vpawn, CombatResolver.LifestealHeal(dealt, ls, 1.0, Config.Stats.LifestealMinHeal), MaxHpOf(st));
double als = EffRun(st, StatKeys.ArmorLifesteal);
if (als > 0) PawnWriter.AddArmorCapped(vpawn, CombatResolver.ArmorLifestealGain(dealt, als, 1.0), MaxArmorOf(st));
}
}
}
if (attacker is { IsValid: true } && !attacker.IsBot && victim is { IsValid: true } && attacker.Slot != victim.Slot)
{
var asid = attacker.AuthorizedSteamID?.SteamId64;
if (asid is not null)
{
if (_dmgReadout.Contains(asid.Value)) // !dmg — true final damage applied (post engine-HS + armor)
attacker.PrintToChat($" {ChatColors.Gold}[dmg]{ChatColors.Default} {HitgroupName(ev.Hitgroup)} | hp {ev.DmgHealth} + armor {ev.DmgArmor} = {ev.DmgHealth + ev.DmgArmor}");
// Burn card (survival): EVERY hit on a bot (re)applies the DoT — flat, armor-skipping, per-attacker; the
// tick timer (Effects.cs) deals it. Independent of DmgHealth so even an armor-only hit still ignites.
if (victim.IsBot && _players.TryGetValue(asid.Value, out var burnPd) && EffectCardMag(burnPd, CardKeys.Burn) > 0)
RegisterBurn(victim.Slot, attacker.Slot, asid.Value);
if (_players.TryGetValue(asid.Value, out var apd) && ev.DmgHealth > 0)
{
bool wasCrit = _critPending.Remove(victim.Slot, out bool c) && c; // this hit's crit (set in the damage hook), shared by XP + lifesteal
// XP on damage: final HP damage × rate + flat headshot/crit bonuses (only for damage to bots).
// The kill bonus is granted separately on EventPlayerDeath. Multipliers apply inside GrantXp.
if (victim.IsBot)
{
double xpBase = ev.DmgHealth * Config.Progression.DamageXpPerHp;
if (ev.Hitgroup == HitgroupHead) xpBase += Config.Progression.HeadshotXpBonus;
if (wasCrit) xpBase += Config.Progression.CritXpBonus;
GrantCombatXp(apd, xpBase, attacker); // survival: banked raw into the run accumulator; else GrantXp
}
// Bloodthirst adds flat lifesteal % for its duration, on top of the stat.
double lsBonus = 0.0, alsBonus = 0.0;
if (Config.Abilities.Enabled && AbilityActive(apd, AbBloodthirst))
{
lsBonus = Config.Abilities.Bloodthirst.Magnitude;
alsBonus = Config.Abilities.Bloodthirst.Magnitude2;
}
double critLs = wasCrit ? Config.Stats.CritLifestealMultiplier : 1.0; // crit hits steal extra (default +50%)
if (attacker.PlayerPawn.Value is { } apawn)
{
var sa = Snapshot(apd); // ONE snapshot shared by both lifesteal reads + their caps
double ls = EffRun(sa, StatKeys.Lifesteal) + lsBonus;
if (ls > 0) PawnWriter.AddHealthCapped(apawn, CombatResolver.LifestealHeal(ev.DmgHealth, ls, critLs, Config.Stats.LifestealMinHeal), MaxHpOf(sa));
double als = EffRun(sa, StatKeys.ArmorLifesteal) + alsBonus;
if (als > 0) PawnWriter.AddArmorCapped(apawn, CombatResolver.ArmorLifestealGain(ev.DmgHealth, als, critLs), MaxArmorOf(sa));
}
}
}
}
return HookResult.Continue;
}
// ---- per-spawn HP/Armor caps (humans; bots get fixed HP from the driver) ----
private HookResult OnPlayerSpawn_Stats(EventPlayerSpawn ev, GameEventInfo info)
{
var p = ev.Userid;
if (!IsHuman(p)) return HookResult.Continue;
NextFrameForSlot(p.Slot, pl => { if (PdOf(pl) is { } pd) ApplyMaxHpArmor(pl, pd); }, requireAlive: true);
return HookResult.Continue;
}
private void ApplyMaxHpArmor(CCSPlayerController p, PlayerData pd)
{
var pawn = p.PlayerPawn.Value;
if (pawn is null || pawn.Health <= 0) return;
int maxHp = MaxHpOf(pd);
PawnWriter.SetMaxHealth(p, pawn, maxHp); // max BEFORE health, else health clamps to 100
PawnWriter.SetHealth(pawn, maxHp);
PawnWriter.SetArmor(pawn, MaxArmorOf(pd));
}
// Regen is ALWAYS ON (no out-of-combat gate — so it's useful mid-fight/clutch) and FLAT, not %-of-max (so it doesn't
// balloon on a maxed-HP tank). HP/armor restored per tick = the stat's flat value (EffRun) — i.e. 1/sec/level at the
// default 1s interval, + any survival regen card. Deliberately small vs a big HP pool: a slow rescue, not a fountain.
private void RegenTick()
{
foreach (var p in Utilities.GetPlayers())
{
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick timer)
var pd = PdOf(p);
if (pd is null) continue;
var pawn = p.PlayerPawn.Value;
if (pawn is null || pawn.Health <= 0) continue;
// FLAT HP/armor per tick (always on), capped at the build's max — the writes live in PawnWriter.
var s = Snapshot(pd); // ONE snapshot shared by the HP + armor regen reads + their caps
PawnWriter.AddHealthCapped(pawn, (int)Math.Round(EffRun(s, StatKeys.HpRegen)), MaxHpOf(s));
PawnWriter.AddArmorCapped(pawn, (int)Math.Round(EffRun(s, StatKeys.ArmorRegen)), MaxArmorOf(s));
}
}
}