using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using Microsoft.Extensions.Logging; namespace CS2_Tags; public class Tag { [JsonPropertyName("prefix")] public string Prefix { get; set; } = ""; [JsonPropertyName("nick_color")] public string NickColor { get; set; } = ""; [JsonPropertyName("message_color")] public string MessageColor { get; set; } = ""; [JsonPropertyName("scoreboard")] public string Scoreboard { get; set; } = ""; // A tag with nothing to show in chat leaves the player's messages to the game. [JsonIgnore] public bool AffectsChat => Prefix != "" || NickColor != "" || MessageColor != ""; } [MinimumApiVersion(375)] public class CS2_Tags : BasePlugin { public override string ModuleName => "CS2-Tags"; public override string ModuleDescription => "Add player tags easily in cs2 game"; public override string ModuleAuthor => "daffyy"; public override string ModuleVersion => "1.0.4c-zio1"; private static string TagsPath => Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", "configs", "plugins", "CS2-Tags", "tags.json"); // Checked in file order after the SteamID64 entries, first match wins, so list higher ranks first. private List> _tags = new(); public override void Load(bool hotReload) { LoadTags(); RegisterListener(OnClientAuthorized); RegisterEventHandler(OnPlayerConnectFull); RegisterEventHandler(OnPlayerSpawn); RegisterEventHandler(OnPlayerDeath); AddCommandListener("say", OnPlayerChat); AddCommandListener("say_team", OnPlayerChatTeam); if (hotReload) ApplyAllClanTags(); } private void LoadTags() { try { if (!File.Exists(TagsPath)) { Directory.CreateDirectory(Path.GetDirectoryName(TagsPath)!); File.WriteAllText(TagsPath, DefaultTagsJson); } var root = JsonNode.Parse(File.ReadAllText(TagsPath), documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); var tags = new List>(); if (root?["tags"] is JsonObject tagsObject) { foreach (var (key, value) in tagsObject) { var tag = value?.Deserialize(); if (tag != null) tags.Add(new(key, tag)); } } _tags = tags; Logger.LogInformation("Loaded {Count} tags from {Path}", _tags.Count, TagsPath); } catch (Exception ex) { // Keep the previous tags, so a typo in the file doesn't strip everyone's tags. Logger.LogError(ex, "Failed to load {Path}", TagsPath); } } [ConsoleCommand("css_tags_reload", "Reload tags.json and reapply scoreboard tags")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_AND_SERVER)] [RequiresPermissions("@css/root")] public void OnReloadConfig(CCSPlayerController? player, CommandInfo info) { LoadTags(); ApplyAllClanTags(); info.ReplyToCommand($"[CS2-Tags] Reloaded {_tags.Count} tags."); } private void OnClientAuthorized(int playerSlot, SteamID steamId) { var player = Utilities.GetPlayerFromSlot(playerSlot); if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return; AddTimer(2.0f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); } private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { var player = @event.Userid; if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return HookResult.Continue; AddTimer(2.0f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } private HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) { var player = @event.Userid; if (player == null || !player.IsValid || player.IsBot) return HookResult.Continue; AddTimer(1.5f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) { var player = @event.Userid; if (player == null || !player.IsValid || player.IsBot) return HookResult.Continue; AddTimer(1.5f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } // Gags and "@" admin chat are CS2-SimpleAdmin's job: its catch-all command listener runs before // these and stops the command, so a gagged player's message never reaches here. private static bool IsCommandOrTrigger(string message) => message.StartsWith('!') || message.StartsWith('@') || message.StartsWith('/') || message.StartsWith('.') || message == "rtv"; private HookResult OnPlayerChat(CCSPlayerController? player, CommandInfo info) { var message = info.GetArg(1); if (player == null || !player.IsValid || message.Length == 0 || IsCommandOrTrigger(message)) return HookResult.Continue; var tag = FindTag(player); if (tag == null || !tag.AffectsChat) return HookResult.Continue; var line = FormatChat(player, tag, message, player.TeamNum); Server.PrintToChatAll($" {line}"); return HookResult.Handled; } private HookResult OnPlayerChatTeam(CCSPlayerController? player, CommandInfo info) { var message = info.GetArg(1); if (player == null || !player.IsValid || message.Length == 0 || IsCommandOrTrigger(message)) return HookResult.Continue; var tag = FindTag(player); if (tag == null || !tag.AffectsChat) return HookResult.Continue; var line = $"{TeamName(player.TeamNum)} {ChatColors.Default}{FormatChat(player, tag, message, player.TeamNum)}"; foreach (var p in Utilities.GetPlayers().Where(p => p is { IsValid: true, IsBot: false } && p.TeamNum == player.TeamNum)) { p.PrintToChat($" {line}"); } return HookResult.Handled; } private string FormatChat(CCSPlayerController player, Tag tag, string message, int teamNum) { string deadIcon = player.TeamNum > (int)CsTeam.Spectator && !player.PawnIsAlive ? $"{ChatColors.White}☠ {ChatColors.Default}" : ""; string prefix = tag.Prefix == "" ? "" : ReplaceTags(tag.Prefix, teamNum).TrimEnd() + " "; string nickColor = tag.NickColor == "" ? TeamColor(teamNum) : ReplaceTags(tag.NickColor, teamNum); string messageColor = tag.MessageColor == "" ? ChatColors.Default.ToString() : ReplaceTags(tag.MessageColor, teamNum); // Colour tags are only replaced in the config's strings, never in the player's name or message. return $"{deadIcon}{prefix}{ChatColors.Default}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{message}"; } // SteamID64 entries win, then groups ("#...") and permissions ("@...") in file order, then "everyone". private Tag? FindTag(CCSPlayerController player) { if (player.IsBot || player.IsHLTV || player.AuthorizedSteamID == null) return null; string steamid = player.AuthorizedSteamID.SteamId64.ToString(); foreach (var (key, tag) in _tags) { if (key == steamid) return tag; } foreach (var (key, tag) in _tags) { if (key.StartsWith('#') && AdminManager.PlayerInGroup(player, key)) return tag; if (key.StartsWith('@') && AdminManager.PlayerHasPermissions(player, key)) return tag; } foreach (var (key, tag) in _tags) { if (key == "everyone") return tag; } return null; } private void ApplyAllClanTags() { foreach (var player in Utilities.GetPlayers()) { SetPlayerClanTag(player); } } private void SetPlayerClanTag(CCSPlayerController? player) { if (player == null || !player.IsValid) return; var tag = FindTag(player); if (tag == null || tag.Scoreboard == "" || player.Clan == tag.Scoreboard) return; player.Clan = tag.Scoreboard; Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan"); } private static string TeamName(int teamNum) { return teamNum switch { (int)CsTeam.Spectator => "(SPEC)", (int)CsTeam.Terrorist => $"{ChatColors.Yellow}(T)", (int)CsTeam.CounterTerrorist => $"{ChatColors.Blue}(CT)", _ => "(NONE)", }; } private static string TeamColor(int teamNum) { return teamNum switch { (int)CsTeam.Terrorist => ChatColors.Gold.ToString(), (int)CsTeam.CounterTerrorist => ChatColors.Blue.ToString(), _ => "", }; } private static string ReplaceTags(string text, int teamNum = 0) { if (!text.Contains('{')) return text; foreach (FieldInfo field in typeof(ChatColors).GetFields(BindingFlags.Public | BindingFlags.Static)) { if (field.FieldType == typeof(char)) text = text.Replace($"{{{field.Name}}}", field.GetValue(null)!.ToString(), StringComparison.OrdinalIgnoreCase); } return text.Replace("{TEAMCOLOR}", TeamColor(teamNum), StringComparison.OrdinalIgnoreCase); } // Written to configs/plugins/CS2-Tags/tags.json on first load. Our CS2-SimpleAdmin rank groups, // highest first; guardian (VIP) comes after staff so a staff member's rank tag wins. private const string DefaultTagsJson = """ { "tags": { "#rank/owner": { "prefix": "{DarkRed}[Owner]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Owner]" }, "#rank/senioradmin": { "prefix": "{Red}[Senior Admin]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Sr. Admin]" }, "#rank/admin": { "prefix": "{Red}[Admin]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Admin]" }, "#rank/trialadmin": { "prefix": "{LightRed}[Trial Admin]", "nick_color": "", "message_color": "", "scoreboard": "[T. Admin]" }, "#rank/mod": { "prefix": "{Blue}[Mod]", "nick_color": "", "message_color": "", "scoreboard": "[Mod]" }, "#rank/trialmod": { "prefix": "{LightBlue}[Trial Mod]", "nick_color": "", "message_color": "", "scoreboard": "[T. Mod]" }, "#rank/helper": { "prefix": "{Green}[Helper]", "nick_color": "", "message_color": "", "scoreboard": "[Helper]" }, "#rank/guardian": { "prefix": "{Gold}[Guardian]", "nick_color": "", "message_color": "", "scoreboard": "[Guardian]" }, "everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" } } } """; }