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 != ""; } // Labels CS2-Tags adds to the chat lines it prints, from tags.json's "chat" section. Empty leaves // the label out. public class ChatLabels { [JsonPropertyName("dead")] public string Dead { get; set; } = "{White}☠"; [JsonPropertyName("team_t")] public string TeamT { get; set; } = "{Yellow}(T)"; [JsonPropertyName("team_ct")] public string TeamCT { get; set; } = "{Blue}(CT)"; [JsonPropertyName("team_spec")] public string TeamSpec { get; set; } = "(SPEC)"; [JsonPropertyName("team_none")] public string TeamNone { get; set; } = "(NONE)"; } [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-zio6"; 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(); private ChatLabels _labels = new(); // zio: GroupVerify, when it's installed (see ScoreboardTag). private VerifyLink? _verify; // zio: players whose own clan tag was replaced because they have no rank tag: slot -> their // SteamID64, their own tag (to put back) and the tag set in its place. private readonly Dictionary _replaced = new(); public override void Load(bool hotReload) { LoadTags(); RegisterListener(OnClientAuthorized); RegisterEventHandler(OnPlayerConnectFull); RegisterEventHandler(OnPlayerSpawn); RegisterEventHandler(OnPlayerDeath); RegisterListener(slot => _replaced.Remove(slot)); AddCommandListener("say", OnPlayerChat); AddCommandListener("say_team", OnPlayerChatTeam); if (hotReload) ApplyAllClanTags(); } // zio: GroupVerify's API is only there after every plugin has loaded. public override void OnAllPluginsLoaded(bool hotReload) { try { _verify = VerifyLink.TryCreate(player => SetPlayerClanTag(player), ApplyAllClanTags); } catch (Exception ex) when (ex is FileNotFoundException or FileLoadException or TypeLoadException) { // GroupVerifyApi isn't in shared/: tags work as they always have. } Logger.LogInformation(_verify == null ? "GroupVerify isn't loaded: players without a rank tag keep their own" : "Using GroupVerify's clan tag for verified players without a rank tag"); if (_verify != null) ApplyAllClanTags(); } public override void Unload(bool hotReload) { _verify?.Dispose(); _verify = null; } 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 labels = root?["chat"]?.Deserialize() ?? new ChatLabels(); 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; _labels = labels; 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 = $"{Label(TeamLabel(player.TeamNum), player.TeamNum)}{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 ? Label(_labels.Dead, teamNum) : ""; 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 || player.IsBot || player.IsHLTV || player.AuthorizedSteamID == null) return; var steamId = player.AuthorizedSteamID.SteamId64; if (_replaced.TryGetValue(player.Slot, out var r) && r.SteamId != steamId) { _replaced.Remove(player.Slot); // left over from whoever had the slot before r = default; } var tag = FindTag(player); string? want = tag is { Scoreboard: not "" } ? tag.Scoreboard : ClanTagFor(player); if (want == null) { // Nothing to show for them any more: put their own tag back if it was replaced. if (!_replaced.Remove(player.Slot)) return; want = r.Own ?? ""; } else if (tag is not { Scoreboard: not "" }) { // Replacing their own tag. Keep it, unless what's there is what was set last time. var own = _replaced.ContainsKey(player.Slot) && player.Clan == r.Set ? r.Own ?? "" : player.Clan ?? ""; _replaced[player.Slot] = (steamId, own, want); } if (player.Clan == want) return; player.Clan = want; Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan"); } // zio: for a player with no rank tag, while GroupVerify has a clan tag set: that tag if they're // verified, none at all if not. null when no clan tag is set, leaving their own tag alone. private string? ClanTagFor(CCSPlayerController player) { var clan = _verify?.ClanTag ?? ""; if (clan == "") return null; return _verify!.IsVerified(player) ? clan : ""; } private string TeamLabel(int teamNum) { return teamNum switch { (int)CsTeam.Spectator => _labels.TeamSpec, (int)CsTeam.Terrorist => _labels.TeamT, (int)CsTeam.CounterTerrorist => _labels.TeamCT, _ => _labels.TeamNone, }; } // A label from the "chat" section with its colour tags replaced and a space after it, or nothing. private static string Label(string label, int teamNum) { return label == "" ? "" : $"{ReplaceTags(label, teamNum).TrimEnd()} {ChatColors.Default}"; } 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. The // chat prefixes match CS2-SimpleAdmin's rank tags in staff chat ("[Mod] name: message"). No // brackets on the scoreboard: CS2 adds its own around the clan tag. private const string DefaultTagsJson = """ { "chat": { "dead": "{White}☠", "team_t": "{Yellow}(T)", "team_ct": "{Blue}(CT)", "team_spec": "(SPEC)", "team_none": "(NONE)" }, "tags": { "#rank/owner": { "prefix": "{Default}[{Lime}O{Default}]", "nick_color": "", "message_color": "", "scoreboard": "OWNER" }, "#rank/senioradmin": { "prefix": "{Default}[{Lime}S.Admin{Default}]", "nick_color": "", "message_color": "", "scoreboard": "S.ADMIN" }, "#rank/admin": { "prefix": "{Default}[{Lime}Admin{Default}]", "nick_color": "", "message_color": "", "scoreboard": "ADMIN" }, "#rank/trialadmin": { "prefix": "{Default}[{Lime}T.Admin{Default}]", "nick_color": "", "message_color": "", "scoreboard": "T.ADMIN" }, "#rank/mod": { "prefix": "{Default}[{Lime}Mod{Default}]", "nick_color": "", "message_color": "", "scoreboard": "MOD" }, "#rank/trialmod": { "prefix": "{Default}[{Lime}T.Mod{Default}]", "nick_color": "", "message_color": "", "scoreboard": "T.MOD" }, "#rank/helper": { "prefix": "{Default}[{Lime}Helper{Default}]", "nick_color": "", "message_color": "", "scoreboard": "HELPER" }, "#rank/guardian": { "prefix": "{Default}[{Lime}Guardian{Default}]", "nick_color": "", "message_color": "", "scoreboard": "GUARDIAN" }, "everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" } } } """; }