diff --git a/CS2-Tags.cs b/CS2-Tags.cs index ec61549..af56b24 100644 --- a/CS2-Tags.cs +++ b/CS2-Tags.cs @@ -27,19 +27,37 @@ public class Tag 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-zio2"; + public override string ModuleVersion => "1.0.4c-zio7"; 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) { @@ -49,6 +67,7 @@ public class CS2_Tags : BasePlugin RegisterEventHandler(OnPlayerConnectFull); RegisterEventHandler(OnPlayerSpawn); RegisterEventHandler(OnPlayerDeath); + RegisterListener(slot => _replaced.Remove(slot)); AddCommandListener("say", OnPlayerChat); AddCommandListener("say_team", OnPlayerChatTeam); @@ -56,6 +75,32 @@ public class CS2_Tags : BasePlugin 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"); + // Only after a hot reload: at server start nobody is connected, and the engine's globals + // (which GetPlayers needs) don't exist yet. + if (_verify != null && hotReload) + ApplyAllClanTags(); + } + + public override void Unload(bool hotReload) + { + _verify?.Dispose(); + _verify = null; + } + private void LoadTags() { try @@ -68,6 +113,7 @@ public class CS2_Tags : BasePlugin 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) { @@ -80,6 +126,7 @@ public class CS2_Tags : BasePlugin } _tags = tags; + _labels = labels; Logger.LogInformation("Loaded {Count} tags from {Path}", _tags.Count, TagsPath); } catch (Exception ex) @@ -160,7 +207,7 @@ public class CS2_Tags : BasePlugin 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)}"; + 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}"); @@ -171,7 +218,7 @@ public class CS2_Tags : BasePlugin 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 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); @@ -215,26 +262,61 @@ public class CS2_Tags : BasePlugin private void SetPlayerClanTag(CCSPlayerController? player) { - if (player == null || !player.IsValid) return; + 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); - if (tag == null || tag.Scoreboard == "" || player.Clan == tag.Scoreboard) return; + 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); + } - player.Clan = tag.Scoreboard; + if (player.Clan == want) return; + player.Clan = want; Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan"); } - private static string TeamName(int teamNum) + // 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 => "(SPEC)", - (int)CsTeam.Terrorist => $"{ChatColors.Yellow}(T)", - (int)CsTeam.CounterTerrorist => $"{ChatColors.Blue}(CT)", - _ => "(NONE)", + (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 @@ -260,19 +342,27 @@ public class CS2_Tags : BasePlugin // 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 - // abbreviations match CS2-SimpleAdmin's staff chat ("[M] name: message"). + // 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": "[O]" }, - "#rank/senioradmin": { "prefix": "{Default}[{Lime}SA{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[SA]" }, - "#rank/admin": { "prefix": "{Default}[{Lime}A{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[A]" }, - "#rank/trialadmin": { "prefix": "{Default}[{Lime}TA{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[TA]" }, - "#rank/mod": { "prefix": "{Default}[{Lime}M{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[M]" }, - "#rank/trialmod": { "prefix": "{Default}[{Lime}TM{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[TM]" }, - "#rank/helper": { "prefix": "{Default}[{Lime}H{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[H]" }, - "#rank/guardian": { "prefix": "{Default}[{Lime}G{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[G]" }, - "everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" } + "#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": "" } } } """; diff --git a/CS2-Tags.csproj b/CS2-Tags.csproj index 2dca71c..f162b53 100644 --- a/CS2-Tags.csproj +++ b/CS2-Tags.csproj @@ -15,6 +15,12 @@ runtime compile; build; native; contentfiles; analyzers; buildtransitive + + + lib/GroupVerifyApi.dll + false + diff --git a/README.md b/README.md index a2e222b..79e6cad 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ CounterStrikeSharp 1.0.375 (net10.0) and meant to run alongside CS2-SimpleAdmin. (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. +- Verification tags from GroupVerify (see below). - 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. @@ -26,17 +27,19 @@ CounterStrikeSharp 1.0.375 (net10.0) and meant to run alongside CS2-SimpleAdmin. ## Configuration `addons/counterstrikesharp/configs/plugins/CS2-Tags/tags.json`, written on first load with a tag -for each of our SimpleAdmin rank groups. They use the same abbreviations as SimpleAdmin's staff chat -(O, SA, A, TA, M, TM, H, G): `[M] name: message` in chat, with a white `[ ]`, lime rank and -team-coloured name, and `[M]` on the scoreboard. An example with other kinds of key: +for each of our SimpleAdmin rank groups. The chat prefixes are the same rank tags SimpleAdmin uses in +staff chat (O, S.Admin, Admin, T.Admin, Mod, T.Mod, Helper, Guardian): `[Mod] name: message`, +with a white `[ ]`, lime rank and team-coloured name. The scoreboard shows OWNER, S.ADMIN, ADMIN, +T.ADMIN, MOD, T.MOD, HELPER and GUARDIAN (CS2 adds the brackets itself). An example with other +kinds of key: ```jsonc { "tags": { - "76561198000000000": { "prefix": "{Magenta}[Dev]", "nick_color": "", "message_color": "", "scoreboard": "[Dev]" }, - "#rank/owner": { "prefix": "{Default}[{Lime}O{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[O]" }, - "#rank/mod": { "prefix": "{Default}[{Lime}M{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[M]" }, - "@css/vip": { "prefix": "{Gold}[VIP]", "nick_color": "", "message_color": "", "scoreboard": "[VIP]" }, + "76561198000000000": { "prefix": "{Magenta}[Dev]", "nick_color": "", "message_color": "", "scoreboard": "Dev" }, + "#rank/owner": { "prefix": "{Default}[{Lime}O{Default}]", "nick_color": "", "message_color": "", "scoreboard": "OWNER" }, + "#rank/mod": { "prefix": "{Default}[{Lime}Mod{Default}]", "nick_color": "", "message_color": "", "scoreboard": "MOD" }, + "@css/vip": { "prefix": "{Gold}[VIP]", "nick_color": "", "message_color": "", "scoreboard": "VIP" }, "everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" } } } @@ -53,7 +56,22 @@ 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. +- `scoreboard` - clan tag on the scoreboard, without brackets (CS2 adds them); empty leaves the + player's own clan tag. + +The optional `chat` section sets the labels CS2-Tags adds to the chat lines it prints (only for +players whose tag changes their chat). Leave a key out for its default, or set it to `""` to drop +the label: + +```jsonc +"chat": { + "dead": "{White}☠", // before a dead player's line + "team_t": "{Yellow}(T)", // start of team chat, per team + "team_ct": "{Blue}(CT)", + "team_spec": "(SPEC)", + "team_none": "(NONE)" +} +``` Colours: any `ChatColors` field name in braces, case-insensitive, e.g. `{Red}`, `{LightBlue}`, `{Gold}`, `{Grey}`, plus `{TEAMCOLOR}`. @@ -62,6 +80,25 @@ Group membership comes from CS2-SimpleAdmin (`css_addadmin ... -g`, `css_addgrou `css_reloadadmins`). After changing ranks or `tags.json`, run `css_tags_reload` to reapply scoreboard tags without waiting for a respawn. +## Verified players' clan tag (GroupVerify) + +When GroupVerify (`plugins/GroupVerify`) is installed and has a clan tag set (`css_gv_clantag`, +or the web panel's Verification page), players **without a rank tag** get it in place of their own +Steam group tag: + +| | verified | not verified | +|-----------------------------------|----------------|----------------| +| a scoreboard tag from `tags.json` | their rank tag | their rank tag | +| everyone else | the clan tag | no tag at all | + +Staff keep their rank tag either way. With no clan tag set, GroupVerify turned off or not +installed, everyone else keeps their own tag, as before; turning the clan tag off puts back the +tag each player had. Tags update as soon as someone's verification or the clan tag changes. + +GroupVerify's API is a compile-only reference (`lib/GroupVerifyApi.dll`, a copy of +`plugins/GroupVerify`'s build; GroupVerify installs the real one in `shared/GroupVerifyApi`). +CS2-Tags loads without it. + ## Commands - `css_tags_reload` (`@css/root` or server console) - reload `tags.json` and reapply scoreboard tags. @@ -75,4 +112,4 @@ FORGEJO_TOKEN=... ./release.sh v `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-zio2` now). +(`v1.0.4c-zio7` now). diff --git a/VerifyLink.cs b/VerifyLink.cs new file mode 100644 index 0000000..5935ba1 --- /dev/null +++ b/VerifyLink.cs @@ -0,0 +1,51 @@ +using CounterStrikeSharp.API.Core; +using GroupVerifyApi; + +namespace CS2_Tags; + +// zio: GroupVerify's API (shared/GroupVerifyApi). Kept in its own class, and only created inside a +// try in OnAllPluginsLoaded, so CS2-Tags still loads, and tags as before, when GroupVerify isn't +// installed and its assembly can't be found. +internal sealed class VerifyLink : IDisposable +{ + private readonly IGroupVerifyApi _api; + private readonly Action _playerChanged; + private readonly Action _tagChanged; + + private VerifyLink(IGroupVerifyApi api, Action playerChanged, Action tagChanged) + { + _api = api; + _playerChanged = playerChanged; + _tagChanged = tagChanged; + _api.OnVerifiedChanged += OnVerifiedChanged; + _api.OnClanTagChanged += _tagChanged; + } + + // The API, or null when GroupVerify isn't loaded. + public static VerifyLink? TryCreate(Action playerChanged, Action tagChanged) + { + IGroupVerifyApi? api; + try + { + api = IGroupVerifyApi.Capability.Get(); + } + catch (KeyNotFoundException) + { + return null; + } + return api == null ? null : new VerifyLink(api, playerChanged, tagChanged); + } + + // The tag verified players without a rank get, "" while none is set or GroupVerify is off. + public string ClanTag => _api.ClanTag; + + public bool IsVerified(CCSPlayerController player) => _api.IsVerified(player); + + private void OnVerifiedChanged(CCSPlayerController player, VerifyStatus status) => _playerChanged(player); + + public void Dispose() + { + _api.OnVerifiedChanged -= OnVerifiedChanged; + _api.OnClanTagChanged -= _tagChanged; + } +} diff --git a/lib/GroupVerifyApi.dll b/lib/GroupVerifyApi.dll new file mode 100644 index 0000000..6f187f9 Binary files /dev/null and b/lib/GroupVerifyApi.dll differ diff --git a/release.sh b/release.sh index 06e9b35..c3f479f 100755 --- a/release.sh +++ b/release.sh @@ -19,8 +19,13 @@ 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. +# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the +# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their +# repos, mode 600). Don't commit a real token. +TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token" +if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then + FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +fi FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" # Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj @@ -37,7 +42,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 exit 1 fi for tool in podman jq curl git; do