While GroupVerify has a clan tag set (css_gv_clantag or the web panel), players without a rank tag get it on the scoreboard if they're verified, and no tag at all if they aren't. Staff keep their rank tag either way. With no clan tag, or GroupVerify off or missing, nothing changes; clearing it gives players their own tags back. Tags update as soon as verification or the clan tag changes (OnVerifiedChanged, OnClanTagChanged). GroupVerifyApi is a compile-only reference (lib/), optional at runtime. Also (zio5, not released on its own): the scoreboard spells ranks in capitals (OWNER, S.ADMIN, ADMIN, ...) and chat prefixes use SimpleAdmin's staff chat rank names ([Mod], [S.Admin], ...).
367 lines
14 KiB
C#
367 lines
14 KiB
C#
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<KeyValuePair<string, Tag>> _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<int, (ulong SteamId, string Own, string Set)> _replaced = new();
|
|
|
|
public override void Load(bool hotReload)
|
|
{
|
|
LoadTags();
|
|
|
|
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
|
|
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
|
|
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn);
|
|
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
|
|
RegisterListener<Listeners.OnClientDisconnect>(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<ChatLabels>() ?? new ChatLabels();
|
|
var tags = new List<KeyValuePair<string, Tag>>();
|
|
if (root?["tags"] is JsonObject tagsObject)
|
|
{
|
|
foreach (var (key, value) in tagsObject)
|
|
{
|
|
var tag = value?.Deserialize<Tag>();
|
|
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": "" }
|
|
}
|
|
}
|
|
""";
|
|
}
|