300 lines
19 KiB
C#
300 lines
19 KiB
C#
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));
|
||
}
|
||
}
|
||
}
|