CS2-Tags/CS2-Tags.cs
Astra cd90d1bf82 1.0.4c-zio1: CSS 1.0.375 / net10.0, fixes, SimpleAdmin ranks
- 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.
2026-09-27 16:23:45 +01:00

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": "" }
}
}
""";
}