using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using Microsoft.Extensions.Logging; using Outnumbered.Config; using Outnumbered.Data; using Outnumbered.Engine; namespace Outnumbered; // Killstreak abilities, indexed 0..4 in streak/prestige order: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk. // INPUT: slot6-10 command listeners DON'T fire on this build (CS2 selects grenades client-side), and a held grenade // is still throwable via mouse-wheel/lastinv. So instead we grant the ability's grenade only while it's castable, and // the instant that grenade becomes the ACTIVE weapon (any selection path: key, wheel, lastinv) we cast the ability, // confiscate the grenade, and switch back to primary — so it can never be thrown. public sealed partial class OutnumberedPlugin { // Internal ability index. Streak tiers and Prestige-I..V unlocks follow this order. private const int AbNoReload = 0, AbAdrenaline = 1, AbOvercharge = 2, AbBloodthirst = 3, AbBerserk = 4; // The ability REGISTRY — one row per killstreak ability, the single place their identity lives. Def selects // the LIVE config block so !og_reload tunables apply. Grenade binds stay in Config.Abilities.AbilityGrenades (JSON). // Adding/reordering an ability = one row here (+ matching grenade in the JSON list). SEE ALSO two index-coupled // sites that must stay reconciled with this ORDER: Ranks.PrestigeColorIdx (prestige tag colours reference ability // indices) and AbilityUnlocked's `pd.Prestige >= i+1` (Prestige I..V unlock abilities 0..4 off-streak). internal sealed record AbilityInfo(int Index, string Name, string Short, string KeyNum, string KeyHint, Func Def); internal static readonly AbilityInfo[] AbilityRegistry = { new(AbNoReload, "No Reload", "NoReload", "6", "HE / key 6", c => c.NoReload), new(AbAdrenaline, "Adrenaline", "Adren", "7", "Flash / key 7", c => c.Adrenaline), new(AbOvercharge, "Overcharge", "Overchg", "8", "Smoke / key 8", c => c.Overcharge), new(AbBloodthirst, "Bloodthirst", "Bloodth", "0", "Molotov / key 0", c => c.Bloodthirst), new(AbBerserk, "Berserk", "Berserk", "9", "Decoy / key 9", c => c.Berserk), }; internal static int AbilityCount => AbilityRegistry.Length; private AbilityDef AbilityCfg(int i) => AbilityRegistry[i].Def(Config.Abilities); private string AbilityGrenadeName(int i) { var list = Config.Abilities.AbilityGrenades; return i >= 0 && i < list.Count ? list[i] : ""; } // Reverse of AbilityGrenadeName: which ability does this grenade weapon trigger? -1 if not an ability grenade. private int AbilityForGrenade(string designerName) { var list = Config.Abilities.AbilityGrenades; for (int i = 0; i < list.Count && i < AbilityCount; i++) if (list[i] == designerName) return i; return -1; } private CounterStrikeSharp.API.Modules.Timers.Timer? _grenadeTimer; private void Initialize_Abilities() { if (AbilityRegistry.Length > PlayerData.AbilitySlots) // the per-player state arrays must hold every ability index Logger.LogError("Outnumbered: AbilityRegistry has {N} rows but PlayerData.AbilitySlots is {Slots} — bump AbilitySlots.", AbilityRegistry.Length, PlayerData.AbilitySlots); // Ranks.PrestigeColorIdx is index-coupled to the ability order; a reorder/addition that references a row past the // registry would mis-colour or crash the prestige tag. Catch it loudly at load (the registry's "one-row" promise). foreach (var set in PrestigeColorIdx) foreach (var i in set) if (i >= AbilityCount) Logger.LogError("Outnumbered: Ranks.PrestigeColorIdx references ability {I} but only {N} abilities exist.", i, AbilityCount); // AbilityForGrenade binds grenade->ability POSITIONALLY via a first-match scan; with 8 instances sharing one JSON // a wrong-length or duplicated list silently casts the wrong/no ability on every server. Validate at load (warn). var grenades = Config.Abilities.AbilityGrenades; if (grenades.Count != AbilityCount) Logger.LogWarning("Outnumbered: AbilityGrenades has {N} entries but there are {C} abilities — extras are ignored and missing rows get no grenade key.", grenades.Count, AbilityCount); var seenGrenades = new HashSet(StringComparer.Ordinal); foreach (var g in grenades) if (g.Length > 0 && !seenGrenades.Add(g)) Logger.LogWarning("Outnumbered: AbilityGrenades lists '{G}' more than once — AbilityForGrenade maps it to the first matching ability only.", g); // The two other ability-indexed presentation lists: AbilityChat (prestige-tag colours) wraps, AbilityIcons falls // back to the registry Short label — warn so a new ability doesn't silently mis-colour / lose its HUD icon. if (AbilityChat.Length < AbilityCount) Logger.LogWarning("Outnumbered: AbilityChat has {N} colours but there are {C} abilities — the prestige tag colour wraps for the extras.", AbilityChat.Length, AbilityCount); if (Config.Hud.AbilityIcons.Count < AbilityCount) Logger.LogWarning("Outnumbered: Hud.AbilityIcons has {N} entries but there are {C} abilities — missing rows fall back to the registry Short label.", Config.Hud.AbilityIcons.Count, AbilityCount); RegisterEventHandler(OnWeaponFire_Abilities); // OnTick is driven by OnTick_All (shared roster walk); OnTick_Abilities is invoked from there. // Reconcile held ability-grenades with castable abilities (grant when ready, remove otherwise). _grenadeTimer = AddTimer(0.5f, ReconcileAllGrenades, CounterStrikeSharp.API.Modules.Timers.TimerFlags.REPEAT); } private void Shutdown_Abilities() { _grenadeTimer?.Kill(); } // ---- state queries (also read by the damage/lifesteal hooks + the HUD) ---- private static bool AbilityActive(PlayerData pd, int i) => pd.AbilityActiveUntil[i] > Server.CurrentTime; // Overload taking a pre-read clock — lets Snapshot() read Server.CurrentTime ONCE for its 3 ability checks (the // clock can't advance within one synchronous build, so it's bit-identical to 3 separate reads). private static bool AbilityActive(PlayerData pd, int i, double now) => pd.AbilityActiveUntil[i] > now; private static bool AbilityReady(PlayerData pd, int i) => Server.CurrentTime >= pd.AbilityReadyAt[i]; // Pre-read-clock overload (mirrors AbilityActive(pd,i,now)) — lets the HUD read Server.CurrentTime ONCE per build. private static bool AbilityReady(PlayerData pd, int i, double now) => now >= pd.AbilityReadyAt[i]; private bool AbilityUnlocked(PlayerData pd, int i) => pd.Streak >= AbilityCfg(i).StreakReq || pd.Prestige >= i + 1; // Prestige I..V unlock 0..4 off-streak // Castable right now = unlocked AND off cooldown. (AbilityReadyAt is set cooldown-ahead on use, so this is // false through the active window too.) Drives both casting and which grenade the player should hold. private bool AbilityUsable(PlayerData pd, int i) => AbilityUnlocked(pd, i) && AbilityReady(pd, i); // ---- grenade-key lifecycle: hold the ability's grenade exactly while that ability is castable ---- private void ReconcileAllGrenades() { if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return; foreach (var p in Utilities.GetPlayers()) { if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path) if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close) if (PdOf(p) is { } pd) ReconcileGrenades(p, pd); } } private void ReconcileGrenades(CCSPlayerController p, PlayerData pd) { for (int i = 0; i < AbilityCount; i++) { string g = AbilityGrenadeName(i); if (g.Length == 0) continue; bool shouldHold = AbilityUsable(pd, i); bool holds = Inventory.Holds(p, g); if (shouldHold && !holds) p.GiveNamedItem(g); // AutoSwitchTo=false on grenades -> no weapon switch else if (!shouldHold && holds) p.RemoveItemByDesignerName(g); } } // ---- input: an ability grenade becoming the active weapon = the player tried to use it ---- private void OnTick_Abilities(List players) { if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return; foreach (var p in players) { if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path) if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close) var active = Inventory.ActiveWeapon(p); if (active is null) continue; int i = AbilityForGrenade(active.DesignerName); if (i < 0) continue; // active weapon isn't an ability grenade — nothing to do var pd = PdOf(p); if (pd is null) continue; // Cast if castable; either way confiscate the grenade and switch to the player's real weapon so it's // never thrown and we never leave a grenade active. MUST be the best weapon they actually hold, not a // hardcoded slot1 — on the Gun Game knife rung there's no primary, so slot1 would no-op and the next // grenade would stay active, casting the NEXT ability every tick. if (AbilityUsable(pd, i)) TryActivateAbility(p, i); p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // no-op if TryActivateAbility already removed it p.ExecuteClientCommand(PreferredSlotCmd(p)); // primary -> pistol -> knife (whatever exists) } } // ---- activation / usability ---- private void TryActivateAbility(CCSPlayerController p, int i) { if (!Config.Abilities.Enabled || i < 0 || i >= AbilityCount) return; var pd = PdOf(p); if (pd is null || !p.PawnIsAlive) return; var def = AbilityCfg(i); string name = AbilityRegistry[i].Name; double now = Server.CurrentTime; if (AbilityActive(pd, i)) { p.PrintToChat($"[Outnumbered] {name} is already active."); return; } if (!AbilityReady(pd, i)) { p.PrintToChat($"[Outnumbered] {name} on cooldown ({pd.AbilityReadyAt[i] - now:F0}s)."); return; } if (!AbilityUnlocked(pd, i)) { p.PrintToChat($"[Outnumbered] {name} locked — need streak {def.StreakReq} (or Prestige {Roman(i + 1)}). Streak is {pd.Streak}."); return; } pd.AbilityActiveUntil[i] = now + def.Duration; // survival -Ability-Cooldown card shortens the cooldown (0 outside a run / without the card). Cooldown starts on // use and keeps ticking through death. double cd = def.Cooldown * Math.Max(0.0, 1.0 - EffectCardMag(pd, CardKeys.AbilityCdr) / 100.0); pd.AbilityReadyAt[i] = now + cd; if (Config.Abilities.GrenadeKeyInput) p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // key dies now; reconciles back after cooldown OnAbilityActivated(p, i); p.PrintToChat($"[Outnumbered] {name} ACTIVE for {def.Duration:F0}s!"); } private void OnAbilityActivated(CCSPlayerController p, int i) { if (i == AbNoReload) RefillActiveWeapon(p, fillClip: true); // top the clip immediately PlaySound(p, Config.Sounds.AbilityActivate); } // ---- weapon ammo (No Reload + baseline infinite reserve) ---- private HookResult OnWeaponFire_Abilities(EventWeaponFire ev, GameEventInfo info) { var p = ev.Userid; if (p is not { IsValid: true } || p.IsBot) return HookResult.Continue; var pd = PdOf(p); if (pd is null) return HookResult.Continue; bool noReload = Config.Abilities.Enabled && AbilityActive(pd, AbNoReload); if (Config.Abilities.InfiniteReserve || noReload) RefillActiveWeapon(p, fillClip: noReload); return HookResult.Continue; } // SetStateChanged not needed for clip/ammo writes. private void RefillActiveWeapon(CCSPlayerController p, bool fillClip) { var w = Inventory.ActiveWeapon(p); if (w is null) return; if (Config.Abilities.InfiniteReserve) w.ReserveAmmo[0] = Config.Abilities.InfiniteReserveAmount; if (fillClip) { int max = Inventory.MaxClip(w); if (max > 0) w.Clip1 = max; } } // ---- !abilities / !perks ---- private void ShowAbilities(CCSPlayerController p) { var pd = PdOf(p); if (pd is null) return; double now = Server.CurrentTime; p.PrintToChat("[Outnumbered] Killstreak abilities — select that grenade to cast, OR type !abilityN"); p.PrintToChat(" (custom binds? use !ability1..5, or bind a key: bind h css_ability1)"); for (int i = 0; i < AbilityCount; i++) { var def = AbilityCfg(i); string state = AbilityActive(pd, i) ? $"ACTIVE {pd.AbilityActiveUntil[i] - now:F0}s" : !AbilityReady(pd, i) ? $"cooldown {pd.AbilityReadyAt[i] - now:F0}s" : AbilityUnlocked(pd, i) ? "READY" : $"locked (streak {def.StreakReq} / Prestige {Roman(i + 1)})"; p.PrintToChat($" {AbilityIcon(i)} {AbilityRegistry[i].Name} [{AbilityRegistry[i].KeyHint} or !ability{i + 1}] — {state}"); } } }