- 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.
278 lines
10 KiB
C#
278 lines
10 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 != "";
|
|
}
|
|
|
|
[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-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<KeyValuePair<string, Tag>> _tags = 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 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;
|
|
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 = $"{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();
|
|
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 static string TeamName(int teamNum)
|
|
{
|
|
return teamNum switch
|
|
{
|
|
(int)CsTeam.Spectator => "(SPEC)",
|
|
(int)CsTeam.Terrorist => $"{ChatColors.Yellow}(T)",
|
|
(int)CsTeam.CounterTerrorist => $"{ChatColors.Blue}(CT)",
|
|
_ => "(NONE)",
|
|
};
|
|
}
|
|
|
|
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.
|
|
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": "" }
|
|
}
|
|
}
|
|
""";
|
|
}
|