From cd90d1bf8232cec38384a3a97f840a5c16879450 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 27 Sep 2026 16:23:45 +0100 Subject: [PATCH] 1.0.4c-zio1: CSS 1.0.375 / net10.0, fixes, SimpleAdmin ranks - Target net10.0 and CounterStrikeSharp 1.0.375 (compile-only reference), System.Text.Json instead of Newtonsoft.Json. - Config moves to configs/plugins/CS2-Tags/tags.json, defaulting to our #rank/... SimpleAdmin groups. Comments and trailing commas allowed; a bad file is logged and the previous tags kept. - Scoreboard tags call SetStateChanged on m_szClan, only when changed. - Colour tags are expanded only in config strings, not in players' names or messages. Empty nick_color means team colour. - A tag with no prefix or colours leaves chat to the game; drop team_chat. - css_tags_reload works for @css/root too and reapplies scoreboard tags; hot reload tags connected players. Timers stop on map change. No dead icon for spectators. - Remove css_tag_mute/unmute and "@" team admin chat: CS2-SimpleAdmin does both, and its catch-all command listener runs before these. - Add release.sh, drop the .sln and GitHub workflow, README for the fork. --- .github/workflows/build.yml | 67 ----- .gitignore | 4 +- CS2-Tags.cs | 571 +++++++++++------------------------- CS2-Tags.csproj | 14 +- CS2-Tags.sln | 22 -- README.md | 121 ++++---- release.sh | 115 ++++++++ 7 files changed, 361 insertions(+), 553 deletions(-) delete mode 100644 .github/workflows/build.yml delete mode 100644 CS2-Tags.sln create mode 100755 release.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 54a7a16..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Build - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -env: - BUILD_NUMBER: ${{ github.run_number }} - PROJECT_PATH: "CS2-Tags.csproj" - PROJECT_NAME: "CS2-Tags" - OUTPUT_PATH: "./CS2-Tags" - -jobs: - build: - permissions: write-all - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 7.0.x - - name: Restore - run: dotnet restore - - name: Build - run: dotnet build ${{ env.PROJECT_PATH }} -c WeaponPaints -o ${{ env.OUTPUT_PATH }} - - publish: - if: github.event_name == 'push' - permissions: write-all - runs-on: ubuntu-latest - needs: build - steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 7.0.x - - name: Restore - run: dotnet restore - - name: Build - run: dotnet build ${{ env.PROJECT_PATH }} -c WeaponPaints -o ${{ env.OUTPUT_PATH }} - - name: Clean files - run: | - rm -f \ - ${{ env.OUTPUT_PATH }}/CounterStrikeSharp.API.dll \ - ${{ env.OUTPUT_PATH }}/McMaster.NETCore.Plugins.dll \ - ${{ env.OUTPUT_PATH }}/Microsoft.DotNet.PlatformAbstractions.dll \ - ${{ env.OUTPUT_PATH }}/Microsoft.Extensions.DependencyModel.dll \ - - name: Zip - uses: thedoctor0/zip-release@0.7.5 - with: - type: 'zip' - filename: '${{ env.PROJECT_NAME }}.zip' - path: ${{ env.OUTPUT_PATH }} - - name: CS2-Tags - uses: ncipollo/release-action@v1.12.0 - with: - artifacts: "${{ env.PROJECT_NAME }}.zip" - name: "Build ${{ env.BUILD_NUMBER }}" - tag: "build-${{ env.BUILD_NUMBER }}" - body: | - Place the plugin in game/csgo/addons/counterstrikesharp/plugins/CS2-Tags - After first server start, tags.json be created diff --git a/.gitignore b/.gitignore index b639bb7..4c1a8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.git .vs +bin/ obj/ -bin/ \ No newline at end of file +release-out/ diff --git a/CS2-Tags.cs b/CS2-Tags.cs index 033714d..5de35e1 100644 --- a/CS2-Tags.cs +++ b/CS2-Tags.cs @@ -1,497 +1,278 @@ -using CounterStrikeSharp.API; +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 Newtonsoft.Json.Linq; -using System.Reflection; +using Microsoft.Extensions.Logging; namespace CS2_Tags; -[MinimumApiVersion(159)] +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 { - private HashSet GaggedIds = new HashSet(); - public static JObject? JsonTags { get; private set; } 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"; + 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) { - CreateOrLoadJsonFile(ModuleDirectory + "/tags.json"); + LoadTags(); - RegisterListener(OnMapStart); RegisterListener(OnClientAuthorized); - RegisterListener(OnClientDisconnect); RegisterEventHandler(OnPlayerConnectFull); RegisterEventHandler(OnPlayerSpawn); RegisterEventHandler(OnPlayerDeath); AddCommandListener("say", OnPlayerChat); AddCommandListener("say_team", OnPlayerChatTeam); + + if (hotReload) + ApplyAllClanTags(); } - private void OnMapStart(string mapName) + private void LoadTags() { - GaggedIds.Clear(); - } - - private static void CreateOrLoadJsonFile(string filepath) - { - if (!File.Exists(filepath)) + try { - var templateData = new JObject + if (!File.Exists(TagsPath)) { - ["tags"] = new JObject - { - ["#css/admin"] = new JObject - { - ["prefix"] = "{RED}[ADMIN]", - ["nick_color"] = "{RED}", - ["message_color"] = "{GOLD}", - ["scoreboard"] = "[ADMIN]" - }, - ["@css/chat"] = new JObject - { - ["prefix"] = "{GREEN}[CHAT]", - ["nick_color"] = "{RED}", - ["message_color"] = "{GOLD}", - ["scoreboard"] = "[CHAT]" - }, - ["76561197961430531"] = new JObject - { - ["prefix"] = "{RED}[ADMIN]", - ["nick_color"] = "{RED}", - ["message_color"] = "{GOLD}", - ["scoreboard"] = "[ADMIN]" - }, - ["everyone"] = new JObject - { - ["team_chat"] = false, - ["prefix"] = "{Grey}[Player]", - ["nick_color"] = "", - ["message_color"] = "", - ["scoreboard"] = "[Player]" - }, - } - }; + Directory.CreateDirectory(Path.GetDirectoryName(TagsPath)!); + File.WriteAllText(TagsPath, DefaultTagsJson); + } - File.WriteAllText(filepath, templateData.ToString()); - var jsonData = File.ReadAllText(filepath); - JsonTags = JObject.Parse(jsonData); + 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); } - else + catch (Exception ex) { - var jsonData = File.ReadAllText(filepath); - JsonTags = JObject.Parse(jsonData); + // 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")] + [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) { - if (player != null) return; - CreateOrLoadJsonFile(ModuleDirectory + "/tags.json"); - - Server.PrintToConsole("[CS2-Tags] Config reloaded!"); - } - - [ConsoleCommand("css_tag_mute")] - [CommandHelper(minArgs: 1, usage: "", whoCanExecute: CommandUsage.SERVER_ONLY)] - public void OnTagMuteCommand(CCSPlayerController? caller, CommandInfo command) - { - string? steamid = command.GetArg(1); - - if (steamid.Length == 17) - { - if (!GaggedIds.Contains(steamid)) - GaggedIds.Add(steamid); - } - } - - [ConsoleCommand("css_tag_unmute")] - [CommandHelper(minArgs: 1, usage: "", whoCanExecute: CommandUsage.SERVER_ONLY)] - public void OnTagUnMuteCommand(CCSPlayerController? caller, CommandInfo command) - { - string? steamid = command.GetArg(1); - - if (steamid.Length == 17) - { - if (GaggedIds.Contains(steamid)) - GaggedIds.Remove(steamid); - } + LoadTags(); + ApplyAllClanTags(); + info.ReplyToCommand($"[CS2-Tags] Reloaded {_tags.Count} tags."); } private void OnClientAuthorized(int playerSlot, SteamID steamId) { - CCSPlayerController? player = Utilities.GetPlayerFromSlot(playerSlot); - + var player = Utilities.GetPlayerFromSlot(playerSlot); if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return; - AddTimer(2.0f, () => SetPlayerClanTag(player)); + AddTimer(2.0f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); } private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { - CCSPlayerController? player = @event.Userid; - + var player = @event.Userid; if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return HookResult.Continue; - AddTimer(2.0f, () => SetPlayerClanTag(player)); - + AddTimer(2.0f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } - private void OnClientDisconnect(int playerSlot) - { - CCSPlayerController? player = Utilities.GetPlayerFromSlot(playerSlot); - - if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return; - - GaggedIds.Remove(player.SteamID.ToString()!); - } - private HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) { - CCSPlayerController? player = @event.Userid; + var player = @event.Userid; if (player == null || !player.IsValid || player.IsBot) return HookResult.Continue; - AddTimer(1.5f, () => SetPlayerClanTag(player)); - + AddTimer(1.5f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) { - CCSPlayerController? player = @event.Userid; + var player = @event.Userid; if (player == null || !player.IsValid || player.IsBot) return HookResult.Continue; - AddTimer(1.5f, () => SetPlayerClanTag(player)); - + 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) { - if (player == null || !player.IsValid || info.GetArg(1).Length == 0 || player.AuthorizedSteamID == null) return HookResult.Continue; - string steamid = player.AuthorizedSteamID.SteamId64.ToString(); + var message = info.GetArg(1); + if (player == null || !player.IsValid || message.Length == 0 || IsCommandOrTrigger(message)) return HookResult.Continue; - if (player.SteamID.ToString() != "" && GaggedIds.Contains(player.SteamID.ToString())) return HookResult.Handled; + var tag = FindTag(player); + if (tag == null || !tag.AffectsChat) return HookResult.Continue; - if (info.GetArg(1).StartsWith("!") || info.GetArg(1).StartsWith("@") || info.GetArg(1).StartsWith("/") || info.GetArg(1).StartsWith(".") || info.GetArg(1) == "rtv") return HookResult.Continue; - - if (JsonTags != null && JsonTags.TryGetValue("tags", out var tags) && tags is JObject tagsObject) - { - string deadIcon = !player.PawnIsAlive ? $"{ChatColors.White}☠ {ChatColors.Default}" : ""; - - if (tagsObject.TryGetValue(steamid, out var playerTag) && playerTag is JObject) - { - string prefix = playerTag["prefix"]?.ToString() ?? ""; - string nickColor = playerTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = playerTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum)); - - return HookResult.Handled; - } - - foreach (var tagKey in tagsObject.Properties()) - { - if (tagKey.Name.StartsWith("#")) - { - string group = tagKey.Name; - bool inGroup = AdminManager.PlayerInGroup(player, group); - - if (inGroup) - { - if (tagsObject.TryGetValue(group, out var groupTag) && groupTag is JObject) - { - string prefix = groupTag["prefix"]?.ToString() ?? ""; - string nickColor = groupTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = groupTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum)); - - return HookResult.Handled; - } - } - } - - if (tagKey.Name.StartsWith("@")) - { - string permission = tagKey.Name; - bool hasPermission = AdminManager.PlayerHasPermissions(player, permission); - - if (hasPermission) - { - if (tagsObject.TryGetValue(permission, out var permissionTag) && permissionTag is JObject) - { - string prefix = permissionTag["prefix"]?.ToString() ?? ""; - string nickColor = permissionTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = permissionTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum)); - - return HookResult.Handled; - } - } - } - } - - if (tagsObject.TryGetValue("everyone", out var everyoneTag) && everyoneTag is JObject && everyoneTag?["team_chat"]?.Value() == true) - { - string prefix = everyoneTag["prefix"]?.ToString() ?? ""; - string nickColor = everyoneTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = everyoneTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum)); - - return HookResult.Handled; - } - } - - return HookResult.Continue; + var line = FormatChat(player, tag, message, player.TeamNum); + Server.PrintToChatAll($" {line}"); + return HookResult.Handled; } private HookResult OnPlayerChatTeam(CCSPlayerController? player, CommandInfo info) { - if (player == null || !player.IsValid || info.GetArg(1).Length == 0 || player.AuthorizedSteamID == null) return HookResult.Continue; + 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(); - - if (player.SteamID.ToString() != "" && GaggedIds.Contains(player.SteamID.ToString())) return HookResult.Handled; - - if (info.GetArg(1).StartsWith("@") && AdminManager.PlayerHasPermissions(player, "@css/chat")) + foreach (var (key, tag) in _tags) { - foreach (var p in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsBot && !p.IsHLTV && AdminManager.PlayerHasPermissions(p, "@css/chat"))) - { - p.PrintToChat($" {ChatColors.Lime}(ADMIN) {ChatColors.Default}{player.PlayerName}: {info.GetArg(1).Remove(0, 1)}"); - } - - return HookResult.Handled; + if (key == steamid) return tag; } - if (info.GetArg(1).StartsWith("!") || info.GetArg(1).StartsWith("@") || info.GetArg(1).StartsWith("/") || info.GetArg(1).StartsWith(".") || info.GetArg(1) == "rtv") return HookResult.Continue; - - if (JsonTags != null && JsonTags.TryGetValue("tags", out var tags) && tags is JObject tagsObject) + foreach (var (key, tag) in _tags) { - string deadIcon = !player.PawnIsAlive ? $"{ChatColors.White}☠ {ChatColors.Default}" : ""; - if (tagsObject.TryGetValue(steamid, out var playerTag) && playerTag is JObject) - { - string prefix = playerTag["prefix"]?.ToString() ?? ""; - string nickColor = playerTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = playerTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot)) - { - string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}"; - p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}"); - } - - return HookResult.Handled; - } - - foreach (var tagKey in tagsObject.Properties()) - { - if (tagKey.Name.StartsWith("#")) - { - string group = tagKey.Name; - bool inGroup = AdminManager.PlayerInGroup(player, group); - - if (inGroup && tagsObject.TryGetValue(group, out var groupTag) && groupTag is JObject) - { - string prefix = groupTag["prefix"]?.ToString() ?? ""; - string nickColor = groupTag["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = groupTag["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot)) - { - string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}"; - p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}"); - } - - return HookResult.Handled; - } - } - - if (tagKey.Name.StartsWith("@")) - { - string permission = tagKey.Name; - bool hasPermission = AdminManager.PlayerHasPermissions(player, permission); - - if (hasPermission && tagsObject.TryGetValue(permission, out var permissionTag) && permissionTag is JObject) - { - string prefix = permissionTag["prefix"]?.ToString() ?? ""; - string nickColor = permissionTag["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = permissionTag["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot)) - { - string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}"; - p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}"); - } - - return HookResult.Handled; - } - } - } - - if (tagsObject.TryGetValue("everyone", out var everyoneTag) && everyoneTag is JObject) - { - string prefix = everyoneTag["prefix"]?.ToString() ?? ""; - string nickColor = everyoneTag["nick_color"]?.ToString() ?? ChatColors.Default.ToString(); - string messageColor = everyoneTag["message_color"]?.ToString() ?? ChatColors.Default.ToString(); - - foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot)) - { - string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}"; - p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}"); - } - //p.PrintToChat(ReplaceTags($" {TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", p.TeamNum)); - - return HookResult.Handled; - } + 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); } - return HookResult.Continue; } private void SetPlayerClanTag(CCSPlayerController? player) { - if (player == null || !player.IsValid || player.IsBot || player.IsHLTV || player.AuthorizedSteamID == null) return; + if (player == null || !player.IsValid) return; - string steamid = player.SteamID!.ToString(); + var tag = FindTag(player); + if (tag == null || tag.Scoreboard == "" || player.Clan == tag.Scoreboard) return; - if (JsonTags != null && JsonTags.TryGetValue("tags", out var tags) && tags is JObject tagsObject) - { - if (tagsObject.TryGetValue(steamid, out var playerTag) && playerTag is JObject) - { - var scoreboardValue = playerTag["scoreboard"]?.ToString(); - if (!string.IsNullOrEmpty(scoreboardValue)) - { - player.Clan = scoreboardValue; - return; - } - } - - foreach (var tagKey in tagsObject.Properties()) - { - if (tagKey.Name.StartsWith("#")) - { - string group = tagKey.Name; - bool inGroup = AdminManager.PlayerInGroup(player, group); - - if (inGroup) - { - if (tagsObject.TryGetValue(group, out var groupTag) && groupTag is JObject) - { - var scoreboardValue = groupTag["scoreboard"]?.ToString(); - if (!string.IsNullOrEmpty(scoreboardValue)) - { - player.Clan = scoreboardValue; - return; - } - } - } - } - - if (tagKey.Name.StartsWith("@")) - { - string permission = tagKey.Name; - bool hasPermission = AdminManager.PlayerHasPermissions(player, permission); - - if (hasPermission) - { - if (tagsObject.TryGetValue(permission, out var permissionTag) && permissionTag is JObject) - { - var scoreboardValue = permissionTag["scoreboard"]?.ToString(); - if (!string.IsNullOrEmpty(scoreboardValue)) - { - player.Clan = scoreboardValue; - return; - } - } - } - } - } - - if (tagsObject.TryGetValue("everyone", out var everyone) && everyone is JObject everyoneObject) - { - var scoreboardValue = everyoneObject["scoreboard"]?.ToString(); - if (!string.IsNullOrEmpty(scoreboardValue)) - { - player.Clan = scoreboardValue; - } - } - } + player.Clan = tag.Scoreboard; + Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan"); } - private string TeamName(int teamNum) + private static string TeamName(int teamNum) { - string teamName = ""; - - switch (teamNum) + return teamNum switch { - case 0: - teamName = $"(NONE)"; - break; - - case 1: - teamName = $"(SPEC)"; - break; - - case 2: - teamName = $"{ChatColors.Yellow}(T)"; - break; - - case 3: - teamName = $"{ChatColors.Blue}(CT)"; - break; - } - - return teamName; + (int)CsTeam.Spectator => "(SPEC)", + (int)CsTeam.Terrorist => $"{ChatColors.Yellow}(T)", + (int)CsTeam.CounterTerrorist => $"{ChatColors.Blue}(CT)", + _ => "(NONE)", + }; } - private string TeamColor(int teamNum) + private static string TeamColor(int teamNum) { - string teamColor; - - switch (teamNum) + return teamNum switch { - case 2: - teamColor = $"{ChatColors.Gold}"; - break; - - case 3: - teamColor = $"{ChatColors.Blue}"; - break; - - default: - teamColor = ""; - break; - } - - return teamColor; + (int)CsTeam.Terrorist => ChatColors.Gold.ToString(), + (int)CsTeam.CounterTerrorist => ChatColors.Blue.ToString(), + _ => "", + }; } - private string ReplaceTags(string message, int teamNum = 0) + private static string ReplaceTags(string text, int teamNum = 0) { - if (message.Contains('{')) + if (!text.Contains('{')) return text; + + foreach (FieldInfo field in typeof(ChatColors).GetFields(BindingFlags.Public | BindingFlags.Static)) { - string modifiedValue = message; - foreach (FieldInfo field in typeof(ChatColors).GetFields()) - { - string pattern = $"{{{field.Name}}}"; - if (message.Contains(pattern, StringComparison.OrdinalIgnoreCase)) - { - modifiedValue = modifiedValue.Replace(pattern, field.GetValue(null)!.ToString(), StringComparison.OrdinalIgnoreCase); - } - } - return modifiedValue.Replace("{TEAMCOLOR}", TeamColor(teamNum)); + if (field.FieldType == typeof(char)) + text = text.Replace($"{{{field.Name}}}", field.GetValue(null)!.ToString(), StringComparison.OrdinalIgnoreCase); } - return message; + return text.Replace("{TEAMCOLOR}", TeamColor(teamNum), StringComparison.OrdinalIgnoreCase); } -} \ No newline at end of file + + // 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": "" } + } + } + """; +} diff --git a/CS2-Tags.csproj b/CS2-Tags.csproj index 0fb8147..945a5b5 100644 --- a/CS2-Tags.csproj +++ b/CS2-Tags.csproj @@ -1,16 +1,20 @@ - + - net7.0 + net10.0 CS2_Tags + CS2-Tags enable enable - true - - + + + none + runtime + compile; build; native; contentfiles; analyzers; buildtransitive + diff --git a/CS2-Tags.sln b/CS2-Tags.sln deleted file mode 100644 index 28a4de2..0000000 --- a/CS2-Tags.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CS2-Tags", "CS2-Tags.csproj", "{06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Debug|Any CPU.Build.0 = Debug|Any CPU - {06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Release|Any CPU.ActiveCfg = Release|Any CPU - {06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/README.md b/README.md index 9ca2eaa..2931f45 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,75 @@ # CS2-Tags -### Do you appreciate what I do? Buy me a cup of tea ❤️ -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y4THKXG) +Our fork of [daffyyyy/CS2-Tags](https://github.com/daffyyyy/CS2-Tags): chat prefixes, name/message +colours and scoreboard (clan) tags, assigned by SteamID64, admin group or permission. Built for +CounterStrikeSharp 1.0.375 (net10.0) and meant to run alongside CS2-SimpleAdmin. -![image](https://github.com/daffyyyy/CS2-Tags/assets/41084667/25dd3f2b-0604-41a2-b2bd-9be230db71e1) -![image](https://github.com/daffyyyy/CS2-Tags/assets/41084667/663a0de1-b875-48fc-bda5-56add5a4833b) +## Changes from upstream 1.0.4c -### Description -Adds tags to the server that can be easily edited, tags can be assigned via permission or steamid64 +- Targets net10.0 / CSS 1.0.375. Uses System.Text.Json, so Newtonsoft.Json no longer ships with it. +- Config moved to `configs/plugins/CS2-Tags/tags.json`. The default file uses our SimpleAdmin rank + groups. Comments and trailing commas are allowed. A broken file is logged and the previous + tags stay in use. +- Scoreboard tags call `SetStateChanged` on `m_szClan` so clients are told about the change. +- Colour tags like `{RED}` are only expanded in the config's strings. Players can no longer colour + their own chat by typing them. +- An empty `nick_color` means the team colour (like normal chat), not whatever colour the prefix + ended on. +- A tag with an empty `prefix`, `nick_color` and `message_color` leaves that player's chat alone + (the game prints it), in both all and team chat. The old `team_chat` key is gone. +- `css_tags_reload` works from the server console and for `@css/root` admins, and reapplies + scoreboard tags straight away. Hot reload applies tags to players already connected. +- Removed `css_tag_mute`/`css_tag_unmute` and the `@` team-chat admin chat. CS2-SimpleAdmin + already does both, and its catch-all command listener runs first. CSS always runs those before + per-command listeners, so gagged players' messages never reach this plugin. -### Commands -- css_tags_reload - Reload tags config +## Configuration -### Configuration -``` +`addons/counterstrikesharp/configs/plugins/CS2-Tags/tags.json`, written with defaults on first load: + +```jsonc { "tags": { - "#css/admin": { // Group - "prefix": "{GREEN}[ADMIN]", // Chat prefix - "nick_color": "{RED}", // Nick color - "message_color": "{GOLD}", // Message nick color - "scoreboard": "[ADMIN]" // Scoreboard tag - }, - "@css/chat": { // Permission - "prefix": "{GREEN}[ADMIN]", // Chat prefix - "nick_color": "{RED}", // Nick color - "message_color": "{GOLD}", // Message nick color - "scoreboard": "[ADMIN]" // Scoreboard tag - }, - "76561198202892670": { // SteamID64 - "prefix": "{GREEN}[ADMIN]", - "nick_color": "{RED}", - "message_color": "{GOLD}", - "scoreboard": "[ADMIN]" - }, - "everyone": { // Tag for everyone, bots excluded - "prefix": "", - "nick_color": "", - "message_color": "", - "scoreboard": "[Player]" - } + "76561198000000000": { "prefix": "{Magenta}[Dev]", "nick_color": "", "message_color": "", "scoreboard": "[Dev]" }, + "#rank/owner": { "prefix": "{DarkRed}[Owner]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Owner]" }, + "#rank/admin": { "prefix": "{Red}[Admin]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Admin]" }, + "@css/vip": { "prefix": "{Gold}[VIP]", "nick_color": "", "message_color": "", "scoreboard": "[VIP]" }, + "everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" } } } ``` -In addons/counterstrikesharp/plugins/CS2-Tags/tags.json -### Requirments -[CounterStrikeSharp](https://github.com/roflmuffin/CounterStrikeSharp/) **tested on v142** +Each player gets exactly one tag, picked in this order: + +1. A SteamID64 key matching the player. +2. The first `#group` or `@permission` key the player matches, **in file order**. Put higher + ranks first. `@css/root` passes every `@css/...` check, so owners match any permission key. +3. `everyone`, if present. + +Fields (all optional, empty means "not set"): + +- `prefix` - shown before the name in chat. +- `nick_color` / `message_color` - colour tags; empty means team colour / default. +- `scoreboard` - clan tag on the scoreboard; empty leaves the player's own clan tag. + +Colours: any `ChatColors` field name in braces, case-insensitive, e.g. `{Red}`, `{LightBlue}`, +`{Gold}`, `{Grey}`, plus `{TEAMCOLOR}`. + +Group membership comes from CS2-SimpleAdmin (`css_addadmin ... -g`, `css_addgroup`, then +`css_reloadadmins`). After changing ranks or `tags.json`, run `css_tags_reload` to reapply +scoreboard tags without waiting for a respawn. + +## Commands + +- `css_tags_reload` (`@css/root` or server console) - reload `tags.json` and reapply scoreboard tags. + +## Building and releasing -### Colors ``` - public static char Default = '\x01'; - public static char White = '\x01'; - public static char Darkred = '\x02'; - public static char Green = '\x04'; - public static char LightYellow = '\x03'; - public static char LightBlue = '\x03'; - public static char Olive = '\x05'; - public static char Lime = '\x06'; - public static char Red = '\x07'; - public static char Purple = '\x03'; - public static char Grey = '\x08'; - public static char Yellow = '\x09'; - public static char Gold = '\x10'; - public static char Silver = '\x0A'; - public static char Blue = '\x0B'; - public static char DarkBlue = '\x0C'; - public static char BlueGrey = '\x0D'; - public static char Magenta = '\x0E'; - public static char LightRed = '\x0F'; +cd plugins/CS2-Tags && ../../build.sh # output in compiled/CS2-Tags/ +FORGEJO_TOKEN=... ./release.sh v ``` -```{TEAMCOLOR} - Team color``` -Use color name for e.g. {LightRed} - -### TODO -- Thinking about better fix for commands handling +`release.sh` rebuilds from the committed source and publishes `CS2-Tags-.tar.gz`, laid out +like `game/csgo/`, to https://git.zio.sh/cs2/CS2-Tags. The tag must equal `v` +(`v1.0.4c-zio1` now). diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..06e9b35 --- /dev/null +++ b/release.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Build CS2-Tags, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh e.g. ./release.sh v0.14.0 +# +# Commit first: the plugin is rebuilt here from the committed source, so the release always matches +# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with +# the same tag replaces that release's attachment. +# +# Asset: CS2-Tags-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf CS2-Tags-.tar.gz -C /srv/cs2/game/csgo +# +# addons/counterstrikesharp/plugins/CS2-Tags/ CS2-Tags.dll, .pdb, .deps.json +# +# No configs/: the plugin writes configs/plugins/CS2-Tags/tags.json itself when it is missing, so an +# upgrade never touches the server's tags. +set -euo pipefail + +FORGEJO_URL="https://git.zio.sh" +OWNER="cs2" +REPO="CS2-Tags" +# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository. +# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token. +FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" + +# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj +# (ExcludeAssets=runtime); the server already provides it. +PLUGIN_FILES=( + CS2-Tags.dll + CS2-Tags.pdb + CS2-Tags.deps.json +) + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "usage: $0 (e.g. v1.0.4c-zio1)" >&2 + exit 1 +fi +if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + exit 1 +fi +for tool in podman jq curl git; do + command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; } +done + +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2 + exit 1 +fi + +# The tag should name the version the plugin reports in `css_plugins list`. +version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' CS2-Tags.cs)" +if [[ "${TAG#v}" != "$version" ]]; then + echo "Tag $TAG doesn't match ModuleVersion $version in CS2-Tags.cs." >&2 + exit 1 +fi + +# --- Build ----------------------------------------------------------------------- +OUT="release-out" +PUBLISH="$OUT/publish" +rm -rf bin obj "$OUT" +podman run --rm -v "$(pwd)":/src:Z -w /src \ + mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH" + +# --- Stage (layout of game/csgo/) and package ---------------------------------------- +STAGE="$OUT/stage" +PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/CS2-Tags" +mkdir -p "$PLUGIN_DIR" +for file in "${PLUGIN_FILES[@]}"; do + cp "$PUBLISH/$file" "$PLUGIN_DIR/" +done + +TARBALL="CS2-Tags-${TAG}.tar.gz" +tar -czf "$OUT/$TARBALL" -C "$STAGE" addons +rm -rf "$STAGE" "$PUBLISH" + +echo "Packaged:" +tar -tzf "$OUT/$TARBALL" + +# --- Tag --------------------------------------------------------------------------- +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + git tag -a "$TAG" -m "$TAG" +fi +git push origin "refs/tags/$TAG" + +# --- Release ----------------------------------------------------------------------- +API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO" +AUTH=(-H "Authorization: token $FORGEJO_TOKEN") + +release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)" +if [[ -z "$release_json" ]]; then + body="$(git log -1 --format=%B "$TAG")" + release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \ + '{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' | + curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")" + echo "Created release $TAG" +else + echo "Release $TAG already exists, replacing its attachment" +fi +release_id="$(jq -r '.id' <<<"$release_json")" + +existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")" +if [[ -n "$existing_id" ]]; then + curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null +fi +curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \ + "$API/releases/$release_id/assets?name=$TARBALL" >/dev/null +echo "Uploaded $TARBALL" + +echo +echo "Download URL:" +echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"