v1.2.3.b6

New skills (9)
- Bounty: put a cash price on an enemy's head, the killer collects it.
- EmpGrenade: grenade victims lose radar and crosshair for a while.
- GravityDecoy: the decoy alters gravity for everyone in its radius.
- Heavyweight: immune to push and slow effects from other skills.
- Nemesis: the marked player takes extra damage from everyone.
- Punisher: banks part of the damage you take and releases it on your next hit.
- Rewind: drop a marker and teleport back to it shortly after.
- TrueArmor: armor absorbs part of incoming damage instead of health.
- Voodoo: the marked enemy shares the damage you take.

Shared systems
- DecoyRing: reusable ground ring so decoy skills show their real area of effect. Adopted by FireRain, FrozenDecoy, GravityDecoy and MagneticDecoy.
- WeaponPool: single cached weapon classification (rifle/pistol/grenade) with a normalizing lookup, backed by the new config Weapons section. Replaces the duplicated hardcoded arrays in Assassin, Chicken, Ghost, NoNades, PrimaryBan, RandomWeapon, SniperElite and WeaponsSwap. [because of compatibility with WeaponRestrict]
- HUD suppression registry in SkillUtils: crosshair and radar hiding is now refcounted per owner, so overlapping skills (Glitch, Jammer, EmpGrenade) cannot restore each other's state early.
- SkillUtils.ResolveHiddenPawns: shared invisible-pawn resolution for C4Camouflage, Ghost and Ninja.
- Config gained a Weapons section with automatic migration for existing installs, plus a per-skill DisableOnPistolRound switch honoured by both the round draw and css_setskill.

Sound
- Volume rebalance
- Add SkillUtils.EmitSoundToPlayer with a private RecipientFilter. A null filter had been broadcasting every skill sound to all clients. Migrated 16 call sites: Poison, DemonEye, ToxicSmoke, CarefulBullets, Thorns, HotBomb, LastGasp, LongKnife, Nightmare, Jester, Tripwire, Medic, HealingSmoke, Rewind, Watchmaker.
- New effects: LongKnife knife-hit feedback (Weapon_Knife.Hit.Heavy/Light), Grapple hook impact, Fortnite barricade placement, Ricochet bounce, Nightmare cursed tone, Replicator/Illusionist spawn, PsychicDefusing defuse cue, TakeAmmo pickup, Tripwire and Jammer trigger tones.
- 29 skills gained a configurable SoundVolume, LongKnife also HitSoundVolume.
- Grapple: emit the hook impact at the hook entity instead of at the owner so the enemy team can locate it.
- PawelJumper: the engine already plays the first jump, only extra jumps emit.

Models and visuals
- Grapple: real hook model with configurable hookScale and hookEmbed so the hook sits in the surface it hit.
- Pilot: glowing exhaust trail parented to the pawn, active only while airborne.

Performance
- SkillsInfo.GetValue now resolves through a ConcurrentDictionary cache instead of reflecting on every call; invalidated on config reload.
- HUD render cache keyed on player, config, description flag and the three source lines; identical frames skip the rebuild.
- Glaz.CheckTransmit: hoist smoke index resolution out of the per-viewer loop and build the pawn-owner map lazily.
- playersSkills switched from ConcurrentBag to HashSet<Skills>, turning the NoRepeat filter from an O(n) scan into a set lookup.

Fixes
- NoRepeat could hand a player two or three skills in one round because the history bag was never deduplicated; the HashSet rewrite closes it.
- SniperElite: restore the saved weapon after the halftime team swap instead of dropping it on the pistol round.
- Illiterate: the holder alert now reaches chat and is exempt from its own scrambler via PrintToChat(ignoreIlliterate).
- EntityManager.DestroyBeam: zero out width and render colour before the kill so beams cannot linger visibly, with a next-frame Remove fallback.
- Ricochet: drop all 18 diagnostic Trace calls and the Trace helper; the skill measured stable over 48h and the tracing dominated the debug log.

Debug
- Tag round start, round end and freeze-time-end lines with [WARMUP] so warmup load is separable from live rounds.
This commit is contained in:
ByDexter 2026-08-09 00:37:02 +03:00
parent 615c55b2da
commit b2d2c5bf9a
93 changed files with 3414 additions and 329 deletions

View file

@ -33,6 +33,9 @@ namespace src.utils
using (var sr = new StreamReader(fs))
json = sr.ReadToEnd();
newConfig = JsonConvert.DeserializeObject<SettingsModel>(json) ?? new SettingsModel();
if (IsSectionMissing(json, nameof(SettingsModel.Weapons)))
SaveConfig(newConfig);
}
catch
{
@ -45,6 +48,18 @@ namespace src.utils
}
}
private static bool IsSectionMissing(string json, string section)
{
try
{
return Newtonsoft.Json.Linq.JObject.Parse(json)[section] == null;
}
catch
{
return false;
}
}
public static void SaveConfig(SettingsModel config)
{
lock (fileLock)
@ -92,6 +107,7 @@ namespace src.utils
public string DisableHUDOnDeathPermission { get; set; }
public bool DisableSkillsOnRoundEnd { get; set; }
public int? CurseSkillPerPlayer { get; set; }
public WeaponPools Weapons { get; set; }
public LanguageSystem LanguageSystem { get; set; }
public HtmlHudCustomisation HtmlHudCustomisation { get; set; }
public ChatMessage ChatMessage { get; set; }
@ -124,6 +140,31 @@ namespace src.utils
DisableSkillsOnRoundEnd = false;
CurseSkillPerPlayer = null;
Weapons = new WeaponPools
{
Rifle =
[
"weapon_ak47", "weapon_m4a1", "weapon_m4a1_silencer",
"weapon_famas", "weapon_galilar", "weapon_aug", "weapon_sg556",
"weapon_mp9", "weapon_mac10", "weapon_bizon", "weapon_mp7",
"weapon_ump45", "weapon_p90", "weapon_mp5sd", "weapon_ssg08",
"weapon_awp", "weapon_scar20", "weapon_g3sg1", "weapon_nova",
"weapon_xm1014", "weapon_mag7", "weapon_sawedoff", "weapon_m249",
"weapon_negev"
],
Pistol =
[
"weapon_deagle", "weapon_revolver", "weapon_glock", "weapon_usp_silencer",
"weapon_cz75a", "weapon_fiveseven", "weapon_p250", "weapon_tec9",
"weapon_elite", "weapon_hkp2000"
],
Grenade =
[
"weapon_hegrenade", "weapon_flashbang", "weapon_smokegrenade",
"weapon_molotov", "weapon_incgrenade", "weapon_decoy"
],
};
LanguageSystem = new LanguageSystem
{
DefaultLangCode = "en",
@ -204,6 +245,13 @@ namespace src.utils
}
}
public class WeaponPools
{
public required List<string> Rifle { get; set; }
public required List<string> Pistol { get; set; }
public required List<string> Grenade { get; set; }
}
public class ChatMessage
{
public required float MaxWidth { get; set; }

View file

@ -0,0 +1,204 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Entities.Constants;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using src.player;
using System.Collections.Concurrent;
using System.Drawing;
using System.Globalization;
namespace src.utils
{
public static class DecoyRing
{
private const string discModel = "models/dev/grenade_trajectory/grenade_target.vmdl";
private const float discBaseRadius = 13.462f;
private const int discAlpha = 178;
private const int outerSegments = 24;
private const int innerSegments = 18;
private const float ringHeight = 3f;
private const float maxLifetime = 25f;
private static readonly ConcurrentDictionary<string, Ring> rings = [];
private sealed class Ring
{
public uint? Disc { get; set; }
public List<uint> Beams { get; } = [];
}
public static void PreloadAssets()
{
jRandomSkills.Instance.AddToManifest(discModel);
}
public static void Show(Skills skill, uint id, Vector center, float radius)
{
if (radius <= 0) return;
string key = MakeKey(skill, id);
var ring = new Ring();
if (!rings.TryAdd(key, ring)) return;
SpawnDisc(center, radius, ring);
if (!EntityManager.OverBudget())
{
Color color = GetSkillColor(skill);
SpawnRing(center, radius, outerSegments, color, ring);
SpawnRing(center, radius * .5f, innerSegments, color, ring);
}
jRandomSkills.Instance.AddTimer(maxLifetime, () => Hide(skill, id), TimerFlags.STOP_ON_MAPCHANGE);
}
public static void Hide(Skills skill, uint id)
{
if (!rings.TryRemove(MakeKey(skill, id), out var ring)) return;
Destroy(ring);
}
public static void ClearAll(Skills skill)
{
string prefix = skill + "|";
foreach (var key in rings.Keys)
{
if (!key.StartsWith(prefix, StringComparison.Ordinal)) continue;
if (!rings.TryRemove(key, out var ring)) continue;
Destroy(ring);
}
}
private static void Destroy(Ring ring)
{
foreach (uint beamIndex in ring.Beams)
EntityManager.DestroyBeam(beamIndex);
ring.Beams.Clear();
if (ring.Disc == null) return;
uint discIndex = ring.Disc.Value;
ring.Disc = null;
EntityManager.DestroyEntity(discIndex, 0f);
}
private static string MakeKey(Skills skill, uint id)
{
return string.Create(CultureInfo.InvariantCulture, $"{skill}|{id}");
}
private static Color GetSkillColor(Skills skill)
{
try
{
string hex = SkillsInfo.GetValue<string>(skill, "color");
if (!string.IsNullOrEmpty(hex))
return ColorTranslator.FromHtml(hex);
}
catch { }
return Color.White;
}
private static void SpawnDisc(Vector center, float radius, Ring ring)
{
var disc = EntityManager.CreateTrackedPropOverride(EntityManager.SystemOwnerIndex);
if (disc == null || !disc.IsValid) return;
uint discIndex = disc.Index;
ring.Disc = discIndex;
float scale = radius / discBaseRadius;
Vector pos = new(center.X, center.Y, center.Z + 1f);
Server.NextFrame(() =>
{
var prop = Utilities.GetEntityFromIndex<CDynamicProp>((int)discIndex);
if (prop == null || !prop.IsValid) return;
var node = prop.CBodyComponent?.SceneNode?.Owner?.Entity;
if (node != null)
node.Flags = (uint)(node.Flags & ~(1 << 2));
MakeNonSolid(prop);
prop.SetModel(discModel);
prop.Teleport(pos, new QAngle(0, 0, 0));
prop.DispatchSpawn();
prop.Render = Color.FromArgb(discAlpha, 255, 255, 255);
Utilities.SetStateChanged(prop, "CBaseModelEntity", "m_clrRender");
var skeleton = prop.CBodyComponent?.SceneNode?.GetSkeletonInstance();
if (skeleton != null)
{
skeleton.Scale = scale;
prop.AcceptInput("SetScale", null, null, scale.ToString(CultureInfo.InvariantCulture));
}
Utilities.SetStateChanged(prop, "CBaseEntity", "m_CBodyComponent");
});
}
private static void MakeNonSolid(CDynamicProp prop)
{
var collision = prop.Collision;
if (collision == null) return;
collision.SolidType = SolidType_t.SOLID_NONE;
collision.SolidFlags = 12;
var attribute = collision.CollisionAttribute;
if (attribute == null) return;
attribute.CollisionGroup = (byte)CollisionGroup.COLLISION_GROUP_NEVER;
attribute.CollisionFunctionMask = 0;
attribute.InteractsAs = 0;
attribute.InteractsWith = 0;
attribute.InteractsExclude = 0;
}
private static void SpawnRing(Vector center, float radius, int segments, Color color, Ring ring)
{
if (segments < 3 || radius <= 0) return;
float step = MathF.Tau / segments;
Vector previous = PointOn(center, radius, 0);
for (int i = 1; i <= segments; i++)
{
Vector next = PointOn(center, radius, step * i);
var beam = EntityManager.CreateTrackedBeam(EntityManager.SystemOwnerIndex, previous, next, color);
if (beam != null && beam.IsValid)
{
beam.Width = 1.2f;
beam.EndWidth = 1.2f;
Utilities.SetStateChanged(beam, "CBeam", "m_fWidth");
Utilities.SetStateChanged(beam, "CBeam", "m_fEndWidth");
ring.Beams.Add(beam.Index);
}
previous = next;
}
}
private static Vector PointOn(Vector center, float radius, float angle)
{
return new Vector(
center.X + MathF.Cos(angle) * radius,
center.Y + MathF.Sin(angle) * radius,
center.Z + ringHeight);
}
}
}

View file

@ -273,6 +273,40 @@ namespace src.utils
return false;
}
public static bool DestroyBeam(uint entityIndex)
{
try
{
var beam = Utilities.GetEntityFromIndex<CBeam>((int)entityIndex);
if (beam != null && beam.IsValid)
{
beam.Width = 0;
beam.EndWidth = 0;
beam.Render = Color.FromArgb(0, 0, 0, 0);
Utilities.SetStateChanged(beam, "CBeam", "m_fWidth");
Utilities.SetStateChanged(beam, "CBeam", "m_fEndWidth");
Utilities.SetStateChanged(beam, "CBaseModelEntity", "m_clrRender");
}
}
catch (Exception ex)
{
Server.PrintToConsole($"[EntityManager] DestroyBeam {entityIndex}: {ex.Message}");
}
bool killed = DestroyEntity(entityIndex);
if (!SuppressKills)
Server.NextFrame(() =>
{
var leftover = Utilities.GetEntityFromIndex<CBeam>((int)entityIndex);
if (leftover != null && leftover.IsValid)
leftover.Remove();
});
return killed;
}
public static bool DestroyEntity<T>(uint? entityIndex) where T : CBaseEntity
{
if (entityIndex == null) return false;

View file

@ -281,7 +281,7 @@ namespace jRandomSkills.src.utils
"weapon_sg556" => KillfeedIcons.Sg556,
"weapon_famas" => KillfeedIcons.Famas,
"weapon_galilar" => KillfeedIcons.Galilar,
"weapon_m4a4" => KillfeedIcons.M4A4,
"weapon_m4a1" => KillfeedIcons.M4A4,
"weapon_m4a1_silencer" => KillfeedIcons.M4A1,
"weapon_ak47" => KillfeedIcons.Ak47,
"weapon_aug" => KillfeedIcons.Aug,

View file

@ -40,7 +40,7 @@ namespace src.utils
LazySig<MemoryFunctionVoid<CBasePlayerPawn, QAngle>>("SnapViewAngles", s => new(s));
// private static readonly int collisionRulesChangedOffset = GameData.GetOffset("CBaseEntity_CollisionRulesChanged");
public static void PrintToChat(CCSPlayerController player, string? msg, string border = "tb", string? title = null)
public static void PrintToChat(CCSPlayerController player, string? msg, string border = "tb", string? title = null, bool ignoreIlliterate = false)
{
if (!player.IsValid) return;
@ -49,7 +49,7 @@ namespace src.utils
char symbol = config.LineSymbol;
if (string.IsNullOrEmpty(title)) title = player.GetTranslation("jRandomSkills");
if (Illiterate.CheckIlliterateSkill(player))
if (!ignoreIlliterate && Illiterate.CheckIlliterateSkill(player))
msg = Illiterate.GetRandomText(msg);
if (border.Contains('t') && config.LineShow)
@ -60,11 +60,39 @@ namespace src.utils
player.PrintToChat($" {MeansureString.GetTextDashed("", maxWidth, symbol, config.LineColor)}");
}
public static void EmitSoundToPlayer(CCSPlayerController? listener, string soundEvent, float volume)
{
var target = PlayerManager.GetPlayerFromEvent(listener);
if (target == null || !target.IsValid) return;
target.EmitSound(soundEvent, new RecipientFilter(target), volume);
}
public static bool IsFreezeTime()
{
return jRandomSkills.Instance?.GameRules?.FreezePeriod == true;
}
public static bool IsPistolRound()
{
var gameRules = jRandomSkills.Instance?.GameRules;
if (gameRules == null) return false;
if (gameRules.TotalRoundsPlayed == 0 || gameRules.GameRestart) return true;
try
{
if (ConVar.Find("mp_halftime")?.GetPrimitiveValue<bool>() != true) return false;
int maxRounds = ConVar.Find("mp_maxrounds")?.GetPrimitiveValue<int>() ?? 0;
return maxRounds > 0 && maxRounds / 2 == gameRules.TotalRoundsPlayed;
}
catch
{
return false;
}
}
public static bool IsHudFrame() => Server.TickCount % 4 == 0;
public static void RegisterSkill(Skills skill, string color, bool display = true)
@ -396,7 +424,8 @@ namespace src.utils
Skills.Deaf, Skills.ExpensiveAmmo, Skills.Giant, Skills.Glitch,
Skills.Jammer, Skills.JumpBan, Skills.JumpCurse, Skills.LifeSwap,
Skills.Magnifier, Skills.MoneySwap, Skills.Nightmare, Skills.Poison,
Skills.JetKick, Skills.PrimaryBan, Skills.Thief, Skills.WildThrow
Skills.JetKick, Skills.PrimaryBan, Skills.Thief, Skills.WildThrow,
Skills.Voodoo, Skills.Nemesis, Skills.Bounty
];
private static readonly HashSet<string> curseSkillNames = new(curseSkills.Select(s => s.ToString()), StringComparer.Ordinal);
@ -490,6 +519,73 @@ namespace src.utils
else curseCounts[victimIndex] = used - 1;
}
private const uint crosshairBit = 1u << 8;
private static readonly object hudSuppressionLock = new();
private static readonly Dictionary<uint, HashSet<string>> crosshairOwners = [];
private static readonly Dictionary<uint, HashSet<string>> radarOwners = [];
public static void SetCrosshairHidden(CCSPlayerController? player, string owner, bool hide)
{
if (player == null || !player.IsValid) return;
var pawn = player.PlayerPawn?.Value;
if (pawn == null || !pawn.IsValid) return;
if (!TryUpdateOwners(crosshairOwners, player.Index, owner, hide, out bool suppressed)) return;
if (suppressed) pawn.HideHUD |= crosshairBit;
else pawn.HideHUD &= ~crosshairBit;
Utilities.SetStateChanged(pawn, "CBasePlayerPawn", "m_iHideHUD");
}
public static void SetRadarDisabled(CCSPlayerController? player, string owner, bool disable)
{
if (player == null || !player.IsValid) return;
if (!TryUpdateOwners(radarOwners, player.Index, owner, disable, out bool suppressed)) return;
player.ReplicateConVar("sv_disable_radar", suppressed ? "1" : "0");
}
public static void ClearHudSuppression(uint playerIndex)
{
lock (hudSuppressionLock)
{
crosshairOwners.Remove(playerIndex);
radarOwners.Remove(playerIndex);
}
}
public static void ClearAllHudSuppression()
{
lock (hudSuppressionLock)
{
crosshairOwners.Clear();
radarOwners.Clear();
}
}
private static bool TryUpdateOwners(Dictionary<uint, HashSet<string>> registry, uint playerIndex, string owner, bool claim, out bool suppressed)
{
lock (hudSuppressionLock)
{
if (!registry.TryGetValue(playerIndex, out var owners))
{
owners = [];
registry[playerIndex] = owners;
}
bool before = owners.Count > 0;
bool changed = claim ? owners.Add(owner) : owners.Remove(owner);
suppressed = owners.Count > 0;
if (!suppressed) registry.Remove(playerIndex);
return changed && before != suppressed;
}
}
public static CCSPlayerController[] GetSelectableEnemies(CCSPlayerController player, bool respectCurseLimit = false)
{
if (player == null || !player.IsValid) return [];
@ -575,6 +671,26 @@ namespace src.utils
return true;
}
public readonly record struct HiddenPawn(uint Index, CsTeam Team, CCSPlayerPawn Pawn, bool HoldsBomb);
public static List<HiddenPawn> ResolveHiddenPawns(ICollection<uint> playerIndexes, uint? bombOwnerIndex)
{
List<HiddenPawn> hidden = new(playerIndexes.Count);
foreach (var playerIndex in playerIndexes)
{
var controller = PlayerManager.GetPlayerEvent(Utilities.GetPlayerFromIndex((int)playerIndex));
if (controller == null || !controller.IsValid) continue;
var pawn = controller.PlayerPawn.Value;
if (pawn == null || !pawn.IsValid) continue;
hidden.Add(new HiddenPawn(controller.Index, controller.Team, pawn, bombOwnerIndex == controller.Index));
}
return hidden;
}
public static void HideCarriedEntities(CCheckTransmitInfo info, CCSPlayerPawn? pawn)
{
if (pawn == null || !pawn.IsValid) return;

View file

@ -21,6 +21,7 @@ namespace src.utils
private static SkillsInfoModel? _indexedConfig;
private static ConcurrentDictionary<string, DefaultSkillInfo> _byName = new();
private static readonly ConcurrentDictionary<(Type Type, string Key), MemberInfo?> _memberCache = new();
private static readonly ConcurrentDictionary<(DefaultSkillInfo Config, string Key, Type Target), object?> _valueCache = new();
public static SkillsInfoModel LoadSkillsInfo()
{
@ -92,6 +93,12 @@ namespace src.utils
if (!_byName.TryGetValue(skill.ToString()!, out var skillConfig) || skillConfig == null)
return default!;
object? cached = _valueCache.GetOrAdd((skillConfig, key, typeof(T)), k => Resolve(k.Config, k.Key, k.Target));
return cached == null ? default! : (T)cached;
}
private static object? Resolve(DefaultSkillInfo skillConfig, string key, Type targetType)
{
var member = _memberCache.GetOrAdd((skillConfig.GetType(), key), k =>
{
MemberInfo? m = k.Type.GetProperty(k.Key, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
@ -106,11 +113,10 @@ namespace src.utils
_ => null
};
if (value == null) return default!;
if (value == null) return null;
Type targetType = typeof(T);
Type? underlyingType = Nullable.GetUnderlyingType(targetType);
return (T)Convert.ChangeType(value, underlyingType ?? targetType);
return Convert.ChangeType(value, underlyingType ?? targetType);
}
private static void EnsureIndex()
@ -124,6 +130,7 @@ namespace src.utils
_byName = dict;
_indexedConfig = config;
_memberCache.Clear();
_valueCache.Clear();
}
public class SkillsInfoModel : ConcurrentBag<DefaultSkillInfo>
@ -147,10 +154,11 @@ namespace src.utils
}
}
public class DefaultSkillInfo(Skills skill, bool active = true, string color = "#ffffff", CsTeam onlyTeam = CsTeam.None, bool disableOnFreezeTime = false, bool needsTeammates = false, string requiredPermission = "", float? hudDuration = null, float? descriptionHudDuration = null, int maxPerServer = -1, Rarity rarity = Rarity.Common)
public class DefaultSkillInfo(Skills skill, bool active = true, string color = "#ffffff", CsTeam onlyTeam = CsTeam.None, bool disableOnFreezeTime = false, bool needsTeammates = false, string requiredPermission = "", float? hudDuration = null, float? descriptionHudDuration = null, int maxPerServer = -1, Rarity rarity = Rarity.Common, bool disableOnPistolRound = false)
{
public bool NeedsTeammates { get; set; } = needsTeammates;
public bool DisableOnFreezeTime { get; set; } = disableOnFreezeTime;
public bool DisableOnPistolRound { get; set; } = disableOnPistolRound;
public int OnlyTeam { get; set; } = (int)onlyTeam;
public string Color { get; set; } = color;
public bool Active { get; set; } = active;

View file

@ -0,0 +1,84 @@
namespace src.utils
{
public static class WeaponPool
{
private static readonly object buildLock = new();
private static bool built;
private static HashSet<string> rifles = new(StringComparer.Ordinal);
private static HashSet<string> pistols = new(StringComparer.Ordinal);
private static HashSet<string> grenades = new(StringComparer.Ordinal);
private static HashSet<string> all = new(StringComparer.Ordinal);
private static string[] rifleArray = [];
private static string[] pistolArray = [];
private static string[] grenadeArray = [];
private static string[] allArray = [];
public static HashSet<string> Rifles { get { Build(); return rifles; } }
public static HashSet<string> Pistols { get { Build(); return pistols; } }
public static HashSet<string> Grenades { get { Build(); return grenades; } }
public static HashSet<string> All { get { Build(); return all; } }
public static string[] RifleList { get { Build(); return rifleArray; } }
public static string[] PistolList { get { Build(); return pistolArray; } }
public static string[] GrenadeList { get { Build(); return grenadeArray; } }
public static string[] AllList { get { Build(); return allArray; } }
public static bool IsRifle(string? weapon) => weapon != null && Rifles.Contains(Normalize(weapon));
public static bool IsPistol(string? weapon) => weapon != null && Pistols.Contains(Normalize(weapon));
public static bool IsGrenade(string? weapon) => weapon != null && Grenades.Contains(Normalize(weapon));
public static bool IsWeapon(string? weapon) => weapon != null && All.Contains(Normalize(weapon));
public static string Normalize(string weapon)
{
if (string.IsNullOrEmpty(weapon)) return string.Empty;
return weapon.StartsWith("weapon_", StringComparison.Ordinal) ? weapon : "weapon_" + weapon;
}
public static void Invalidate()
{
lock (buildLock) built = false;
}
private static void Build()
{
if (built) return;
lock (buildLock)
{
if (built) return;
var pools = Config.LoadedConfig?.Weapons;
rifles = Collect(pools?.Rifle);
pistols = Collect(pools?.Pistol);
grenades = Collect(pools?.Grenade);
all = new HashSet<string>(rifles, StringComparer.Ordinal);
all.UnionWith(pistols);
rifleArray = [.. rifles];
pistolArray = [.. pistols];
grenadeArray = [.. grenades];
allArray = [.. all];
built = true;
}
}
private static HashSet<string> Collect(List<string>? source)
{
var set = new HashSet<string>(StringComparer.Ordinal);
if (source == null) return set;
foreach (var entry in source)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
set.Add(Normalize(entry.Trim()));
}
return set;
}
}
}