CS2-Tags/CS2-Tags.cs
Astra 3ac24ac3fe 1.0.4c-zio4: chat labels in tags.json
The dead marker and the (T)/(CT)/(SPEC)/(NONE) team-chat labels come from a new optional "chat"
section in tags.json, with colour tags; "" drops a label, and missing keys keep the old defaults.
2026-09-28 20:29:29 +01:00

307 lines
12 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-zio4";
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();
public override void Load(bool hotReload)
{
LoadTags();
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn);
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
AddCommandListener("say", OnPlayerChat);
AddCommandListener("say_team", OnPlayerChatTeam);
if (hotReload)
ApplyAllClanTags();
}
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) return;
var tag = FindTag(player);
if (tag == null || tag.Scoreboard == "" || player.Clan == tag.Scoreboard) return;
player.Clan = tag.Scoreboard;
Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan");
}
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
// abbreviations match CS2-SimpleAdmin's staff chat ("[M] name: message"); the scoreboard spells
// the rank out. No brackets there: 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}SA{Default}]", "nick_color": "", "message_color": "", "scoreboard": "Sen.Admin" },
"#rank/admin": { "prefix": "{Default}[{Lime}A{Default}]", "nick_color": "", "message_color": "", "scoreboard": "Admin" },
"#rank/trialadmin": { "prefix": "{Default}[{Lime}TA{Default}]", "nick_color": "", "message_color": "", "scoreboard": "T.Admin" },
"#rank/mod": { "prefix": "{Default}[{Lime}M{Default}]", "nick_color": "", "message_color": "", "scoreboard": "Mod" },
"#rank/trialmod": { "prefix": "{Default}[{Lime}TM{Default}]", "nick_color": "", "message_color": "", "scoreboard": "T.Mod" },
"#rank/helper": { "prefix": "{Default}[{Lime}H{Default}]", "nick_color": "", "message_color": "", "scoreboard": "Helper" },
"#rank/guardian": { "prefix": "{Default}[{Lime}G{Default}]", "nick_color": "", "message_color": "", "scoreboard": "Guardian" },
"everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" }
}
}
""";
}