using System.Collections.Frozen; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Menu; using CounterStrikeSharp.API.Modules.Utils; using Outnumbered.Config; using Outnumbered.Data; using Outnumbered.Engine; namespace Outnumbered; // Free weapon selection (no economy), split into categories. !guns lists the categories; the direct // commands (!rifles/!smgs/!snipers/!shotguns/!heavy/!pistols) jump straight to a category. Choice persists // per-SteamID (DB) and is applied on spawn by ApplyLoadout (default until chosen / if a saved gun is delisted). public sealed partial class OutnumberedPlugin { private static readonly FrozenDictionary WeaponNames = new Dictionary { ["weapon_ak47"] = "AK-47", ["weapon_m4a1_silencer"] = "M4A1-S", ["weapon_m4a1"] = "M4A4", ["weapon_aug"] = "AUG", ["weapon_sg556"] = "SG 553", ["weapon_galilar"] = "Galil AR", ["weapon_famas"] = "FAMAS", ["weapon_awp"] = "AWP", ["weapon_ssg08"] = "SSG 08", ["weapon_scar20"] = "SCAR-20", ["weapon_g3sg1"] = "G3SG1", ["weapon_mp9"] = "MP9", ["weapon_mp7"] = "MP7", ["weapon_mp5sd"] = "MP5-SD", ["weapon_ump45"] = "UMP-45", ["weapon_p90"] = "P90", ["weapon_mac10"] = "MAC-10", ["weapon_bizon"] = "PP-Bizon", ["weapon_nova"] = "Nova", ["weapon_xm1014"] = "XM1014", ["weapon_mag7"] = "MAG-7", ["weapon_sawedoff"] = "Sawed-Off", ["weapon_m249"] = "M249", ["weapon_negev"] = "Negev", ["weapon_deagle"] = "Desert Eagle", ["weapon_revolver"] = "R8 Revolver", ["weapon_glock"] = "Glock-18", ["weapon_usp_silencer"] = "USP-S", ["weapon_hkp2000"] = "P2000", ["weapon_p250"] = "P250", ["weapon_fiveseven"] = "Five-SeveN", ["weapon_tec9"] = "Tec-9", ["weapon_cz75a"] = "CZ75-Auto", ["weapon_elite"] = "Dual Berettas", ["weapon_knife"] = "Knife", // the GG finale rung — otherwise the prefix-strip fallback renders lowercase "knife" }.ToFrozenDictionary(StringComparer.Ordinal); internal static string WeaponDisplayName(string item) => WeaponNames.TryGetValue(item, out var n) ? n : item.StartsWith("weapon_", StringComparison.Ordinal) ? item[7..].Replace('_', ' ') : item; internal static bool WeapEq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); // Cached weapon-category sets (OrdinalIgnoreCase == WeapEq), so membership is O(1) instead of Concat-5-lists + ToList // + a linear .Any(WeapEq) per call. Rebuilt only when the Match config INSTANCE changes (!og_reload swaps the whole // Config object). CONTRACT: an in-place edit to Match.Rifles/Pistols/... would NOT invalidate this — only a Config swap does. private FrozenSet? _primarySet, _pistolSet; private MatchConfig? _weaponSetCfg; private void EnsureWeaponSets() { var m = Config.Match; if (_primarySet is not null && ReferenceEquals(_weaponSetCfg, m)) return; _primarySet = m.Rifles.Concat(m.Smgs).Concat(m.Snipers).Concat(m.Shotguns).Concat(m.Heavy).ToFrozenSet(StringComparer.OrdinalIgnoreCase); _pistolSet = m.Pistols.ToFrozenSet(StringComparer.OrdinalIgnoreCase); _weaponSetCfg = m; } private bool IsPrimaryWeapon(string w) { EnsureWeaponSets(); return _primarySet!.Contains(w); } private bool IsPistolWeapon(string w) { EnsureWeaponSets(); return _pistolSet!.Contains(w); } // Level required to use a weapon (absent/0 = free). Prestige resets level, so this re-locks on prestige. private int UnlockLevel(string w) => Config.Match.WeaponUnlockLevel.TryGetValue(w, out var lvl) ? lvl : 0; // Allowed = in the right category set AND the player has reached its unlock level. private bool IsAllowedPrimary(PlayerData pd, string? w) => !string.IsNullOrEmpty(w) && IsPrimaryWeapon(w!) && pd.Level >= UnlockLevel(w!); private bool IsAllowedSecondary(PlayerData pd, string? w) => !string.IsNullOrEmpty(w) && IsPistolWeapon(w!) && pd.Level >= UnlockLevel(w!); // What the driver hands this player on spawn: saved choice if still allowed (unlocked), else config default. internal (string primary, string secondary) ResolveLoadout(CCSPlayerController p) { string primary = Config.Match.HumanPrimary, secondary = Config.Match.HumanSecondary; if (PdOf(p) is { } pd) { if (IsAllowedPrimary(pd, pd.PrimaryWeapon)) primary = pd.PrimaryWeapon!; if (IsAllowedSecondary(pd, pd.SecondaryWeapon)) secondary = pd.SecondaryWeapon!; } return (primary, secondary); } // Which category command equips this weapon (for the unlock announcement). private string WeaponCategoryCmd(string w) { var m = Config.Match; if (m.Rifles.Any(x => WeapEq(x, w))) return "rifles"; if (m.Smgs.Any(x => WeapEq(x, w))) return "smgs"; if (m.Snipers.Any(x => WeapEq(x, w))) return "snipers"; if (m.Shotguns.Any(x => WeapEq(x, w))) return "shotguns"; if (m.Heavy.Any(x => WeapEq(x, w))) return "heavy"; return "pistols"; } // ---- quick-buy combo: the keystrokes that reach a weapon in the shop (X -> Weapons -> category -> item) ---- // The 8 reliable menu keys in slot order (key 4 = grenade slot and 5 = C4 can't be clean selectors). private static readonly char[] MenuKeys = ['1', '2', '3', '6', '7', '8', '9', '0']; // Category order on the Weapons screen — fixes each category's key. Item keys come from the JSON list order. public static readonly string[] CategoryOrder = ["Pistols", "Smgs", "Shotguns", "Rifles", "Heavy", "Snipers"]; private List CategoryList(string name) => name switch { "Rifles" => Config.Match.Rifles, "Smgs" => Config.Match.Smgs, "Snipers" => Config.Match.Snipers, "Shotguns" => Config.Match.Shotguns, "Heavy" => Config.Match.Heavy, "Pistols" => Config.Match.Pistols, _ => [], }; public static char MenuKey(int pos) => pos >= 0 && pos < MenuKeys.Length ? MenuKeys[pos] : '?'; // Derived, never stored: '1' = Weapons branch at root, then the category's key, then the weapon's key. // Falls out of the JSON list order automatically, so it can never drift from the menu. "" if not found, OR if the item // sits past the world-menu's key range (idx >= MenuKeys.Length) — those overflow guns have no quick-buy combo and are // reached via the paginated !category chat menu, so we omit the combo rather than print an unreachable key ('?'). private string QuickBuySequence(string weapon) { for (int c = 0; c < CategoryOrder.Length; c++) { int idx = CategoryList(CategoryOrder[c]).FindIndex(x => WeapEq(x, weapon)); if (idx >= 0) return idx < MenuKeys.Length ? $"X{MenuKey(0)}{MenuKey(c)}{MenuKey(idx)}" : ""; } return ""; } // Loud, distinct chat shout for any weapon(s) that unlock exactly at this level (called on level-up). private void AnnounceUnlocks(CCSPlayerController p, int level) { foreach (var kv in Config.Match.WeaponUnlockLevel) if (kv.Value == level) { string combo = QuickBuySequence(kv.Key); string tail = combo.Length > 0 ? $" or quick-buy {ChatColors.Gold}{combo}{ChatColors.Default}" : ""; p.PrintToChat($" {ChatColors.Magenta}★ WEAPON UNLOCKED: {WeaponDisplayName(kv.Key)} {ChatColors.Default}— !{WeaponCategoryCmd(kv.Key)} to equip{tail}"); } } // ---- top menu + category commands ---- [ConsoleCommand("css_guns", "Open the weapon selection menu")] [ConsoleCommand("css_weapons", "Open the weapon selection menu")] [ConsoleCommand("css_loadout", "Open the weapon selection menu")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Guns(CCSPlayerController? p, CommandInfo info) { if (p is not { IsValid: true }) return; if (!_driver.WeaponShopEnabled) { p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Your weapon is set by the Gun Game ladder — climb it!"); return; } var m = Config.Match; var (cur1, cur2) = ResolveLoadout(p); p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Loadout — {WeaponDisplayName(cur1)} / {WeaponDisplayName(cur2)} (pick a category)"); p.PrintToChat($" {ChatColors.Lime} !rifles — Rifles ({m.Rifles.Count})"); p.PrintToChat($" {ChatColors.Lime} !smgs — SMGs ({m.Smgs.Count})"); p.PrintToChat($" {ChatColors.Lime} !snipers — Snipers ({m.Snipers.Count})"); p.PrintToChat($" {ChatColors.Lime} !shotguns — Shotguns ({m.Shotguns.Count})"); p.PrintToChat($" {ChatColors.Lime} !heavy — Machine Guns ({m.Heavy.Count})"); p.PrintToChat($" {ChatColors.Lime} !pistols — Pistols ({m.Pistols.Count})"); } [ConsoleCommand("css_rifles", "Choose a rifle")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Rifles(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Rifles", Config.Match.Rifles, true); } [ConsoleCommand("css_smgs", "Choose an SMG")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Smgs(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "SMGs", Config.Match.Smgs, true); } [ConsoleCommand("css_snipers", "Choose a sniper")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Snipers(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Snipers", Config.Match.Snipers, true); } [ConsoleCommand("css_shotguns", "Choose a shotgun")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Shotguns(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Shotguns", Config.Match.Shotguns, true); } [ConsoleCommand("css_heavy", "Choose a machine gun")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Heavy(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Heavy", Config.Match.Heavy, true); } [ConsoleCommand("css_pistols", "Choose a pistol")] [ConsoleCommand("css_secondaries", "Choose a pistol")] [CommandHelper(0, "", CommandUsage.CLIENT_ONLY)] public void Cmd_Pistols(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Pistols", Config.Match.Pistols, false); } private void OpenCategory(CCSPlayerController p, string title, List list, bool primary) { if (!_driver.WeaponShopEnabled) { p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Weapon selection is disabled in Gun Game."); return; } var pd = PdOf(p); if (pd is null) return; var menu = new ChatMenu($"Choose {title}"); foreach (var item in list) { int req = UnlockLevel(item); bool unlocked = pd.Level >= req; string label = unlocked ? WeaponDisplayName(item) : $"{WeaponDisplayName(item)} [Lv {req}]"; menu.AddMenuOption(label, (pl, _) => SetWeapon(pl, primary, item), disabled: !unlocked); } menu.Open(p); } private void SetWeapon(CCSPlayerController p, bool primary, string item) { var pd = PdOf(p); if (pd is null) return; int req = UnlockLevel(item); if (pd.Level < req) { p.PrintToChat($"[Outnumbered] {WeaponDisplayName(item)} unlocks at level {req}."); return; } if (primary) pd.PrimaryWeapon = item; else pd.SecondaryWeapon = item; pd.Dirty = true; string slotName = primary ? "Primary" : "Secondary"; if (p.PawnIsAlive) { // Rebuild both slots synchronously. RemoveItemBySlot's kill is DEFERRED 0.1s (would double-occupy the // slot / leave the player unarmed); RemoveWeapons() is synchronous (mirrors ApplyLoadout). Armor isn't // a weapon so it survives; an ability grenade (if any) reconciles back. var (pri, sec) = ResolveLoadout(p); p.RemoveWeapons(); p.GiveNamedItem(EngineNames.WeaponKnife); if (!string.IsNullOrEmpty(pri)) p.GiveNamedItem(pri); if (!string.IsNullOrEmpty(sec)) p.GiveNamedItem(sec); GiveShopCarriers(p); // re-give the healthshot + zeus after the rebuild (armor isn't a weapon, so RemoveWeapons left it) p.PrintToChat($"[Outnumbered] {slotName}: {WeaponDisplayName(item)}."); } else { p.PrintToChat($"[Outnumbered] {slotName}: {WeaponDisplayName(item)} (applies on respawn)."); } } }