cs2-outnumbered/Outnumbered/Weapons.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

249 lines
13 KiB
C#

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<string, string> WeaponNames = new Dictionary<string, string>
{
["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<string>? _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<string> 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<string> 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).");
}
}
}