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 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 _dmgReadout = new(); // !dmg — players seeing the per-hit final-damage readout private readonly Dictionary _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(OnEntityTakeDamagePre); RegisterEventHandler(OnPlayerHurt_Stats); RegisterEventHandler(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()); 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)); } } }