CSS runs OnAllPluginsLoaded before the engine's globals exist, so Utilities.GetPlayers() threw
("Global Variables not initialized yet"). Nobody is connected then anyway; tags are only
reapplied there after a hot reload.
369 lines
14 KiB
C#
369 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-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<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");
|
|
// 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
|
|
{
|
|
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": "" }
|
|
}
|
|
}
|
|
""";
|
|
}
|