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.
This commit is contained in:
Astra 2026-09-27 16:23:45 +01:00
parent 0d5c8ec061
commit cd90d1bf82
7 changed files with 361 additions and 553 deletions

View file

@ -1,67 +0,0 @@
name: Build
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
BUILD_NUMBER: ${{ github.run_number }}
PROJECT_PATH: "CS2-Tags.csproj"
PROJECT_NAME: "CS2-Tags"
OUTPUT_PATH: "./CS2-Tags"
jobs:
build:
permissions: write-all
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build ${{ env.PROJECT_PATH }} -c WeaponPaints -o ${{ env.OUTPUT_PATH }}
publish:
if: github.event_name == 'push'
permissions: write-all
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build ${{ env.PROJECT_PATH }} -c WeaponPaints -o ${{ env.OUTPUT_PATH }}
- name: Clean files
run: |
rm -f \
${{ env.OUTPUT_PATH }}/CounterStrikeSharp.API.dll \
${{ env.OUTPUT_PATH }}/McMaster.NETCore.Plugins.dll \
${{ env.OUTPUT_PATH }}/Microsoft.DotNet.PlatformAbstractions.dll \
${{ env.OUTPUT_PATH }}/Microsoft.Extensions.DependencyModel.dll \
- name: Zip
uses: thedoctor0/zip-release@0.7.5
with:
type: 'zip'
filename: '${{ env.PROJECT_NAME }}.zip'
path: ${{ env.OUTPUT_PATH }}
- name: CS2-Tags
uses: ncipollo/release-action@v1.12.0
with:
artifacts: "${{ env.PROJECT_NAME }}.zip"
name: "Build ${{ env.BUILD_NUMBER }}"
tag: "build-${{ env.BUILD_NUMBER }}"
body: |
Place the plugin in game/csgo/addons/counterstrikesharp/plugins/CS2-Tags
After first server start, tags.json be created

4
.gitignore vendored
View file

@ -1,4 +1,4 @@
.git
.vs
bin/
obj/
bin/
release-out/

View file

@ -1,497 +1,278 @@
using CounterStrikeSharp.API;
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 Newtonsoft.Json.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
namespace CS2_Tags;
[MinimumApiVersion(159)]
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
{
private HashSet<string> GaggedIds = new HashSet<string>();
public static JObject? JsonTags { get; private set; }
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";
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)
{
CreateOrLoadJsonFile(ModuleDirectory + "/tags.json");
LoadTags();
RegisterListener<Listeners.OnMapStart>(OnMapStart);
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn);
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
AddCommandListener("say", OnPlayerChat);
AddCommandListener("say_team", OnPlayerChatTeam);
if (hotReload)
ApplyAllClanTags();
}
private void OnMapStart(string mapName)
private void LoadTags()
{
GaggedIds.Clear();
}
private static void CreateOrLoadJsonFile(string filepath)
{
if (!File.Exists(filepath))
try
{
var templateData = new JObject
if (!File.Exists(TagsPath))
{
["tags"] = new JObject
{
["#css/admin"] = new JObject
{
["prefix"] = "{RED}[ADMIN]",
["nick_color"] = "{RED}",
["message_color"] = "{GOLD}",
["scoreboard"] = "[ADMIN]"
},
["@css/chat"] = new JObject
{
["prefix"] = "{GREEN}[CHAT]",
["nick_color"] = "{RED}",
["message_color"] = "{GOLD}",
["scoreboard"] = "[CHAT]"
},
["76561197961430531"] = new JObject
{
["prefix"] = "{RED}[ADMIN]",
["nick_color"] = "{RED}",
["message_color"] = "{GOLD}",
["scoreboard"] = "[ADMIN]"
},
["everyone"] = new JObject
{
["team_chat"] = false,
["prefix"] = "{Grey}[Player]",
["nick_color"] = "",
["message_color"] = "",
["scoreboard"] = "[Player]"
},
}
};
Directory.CreateDirectory(Path.GetDirectoryName(TagsPath)!);
File.WriteAllText(TagsPath, DefaultTagsJson);
}
File.WriteAllText(filepath, templateData.ToString());
var jsonData = File.ReadAllText(filepath);
JsonTags = JObject.Parse(jsonData);
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);
}
else
catch (Exception ex)
{
var jsonData = File.ReadAllText(filepath);
JsonTags = JObject.Parse(jsonData);
// 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")]
[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)
{
if (player != null) return;
CreateOrLoadJsonFile(ModuleDirectory + "/tags.json");
Server.PrintToConsole("[CS2-Tags] Config reloaded!");
}
[ConsoleCommand("css_tag_mute")]
[CommandHelper(minArgs: 1, usage: "<SteamID>", whoCanExecute: CommandUsage.SERVER_ONLY)]
public void OnTagMuteCommand(CCSPlayerController? caller, CommandInfo command)
{
string? steamid = command.GetArg(1);
if (steamid.Length == 17)
{
if (!GaggedIds.Contains(steamid))
GaggedIds.Add(steamid);
}
}
[ConsoleCommand("css_tag_unmute")]
[CommandHelper(minArgs: 1, usage: "<SteamID>", whoCanExecute: CommandUsage.SERVER_ONLY)]
public void OnTagUnMuteCommand(CCSPlayerController? caller, CommandInfo command)
{
string? steamid = command.GetArg(1);
if (steamid.Length == 17)
{
if (GaggedIds.Contains(steamid))
GaggedIds.Remove(steamid);
}
LoadTags();
ApplyAllClanTags();
info.ReplyToCommand($"[CS2-Tags] Reloaded {_tags.Count} tags.");
}
private void OnClientAuthorized(int playerSlot, SteamID steamId)
{
CCSPlayerController? player = Utilities.GetPlayerFromSlot(playerSlot);
var player = Utilities.GetPlayerFromSlot(playerSlot);
if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return;
AddTimer(2.0f, () => SetPlayerClanTag(player));
AddTimer(2.0f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE);
}
private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info)
{
CCSPlayerController? player = @event.Userid;
var player = @event.Userid;
if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return HookResult.Continue;
AddTimer(2.0f, () => SetPlayerClanTag(player));
AddTimer(2.0f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE);
return HookResult.Continue;
}
private void OnClientDisconnect(int playerSlot)
{
CCSPlayerController? player = Utilities.GetPlayerFromSlot(playerSlot);
if (player == null || !player.IsValid || player.IsBot || player.IsHLTV) return;
GaggedIds.Remove(player.SteamID.ToString()!);
}
private HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info)
{
CCSPlayerController? player = @event.Userid;
var player = @event.Userid;
if (player == null || !player.IsValid || player.IsBot) return HookResult.Continue;
AddTimer(1.5f, () => SetPlayerClanTag(player));
AddTimer(1.5f, () => SetPlayerClanTag(player), TimerFlags.STOP_ON_MAPCHANGE);
return HookResult.Continue;
}
private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
{
CCSPlayerController? player = @event.Userid;
var player = @event.Userid;
if (player == null || !player.IsValid || player.IsBot) return HookResult.Continue;
AddTimer(1.5f, () => SetPlayerClanTag(player));
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)
{
if (player == null || !player.IsValid || info.GetArg(1).Length == 0 || player.AuthorizedSteamID == null) return HookResult.Continue;
string steamid = player.AuthorizedSteamID.SteamId64.ToString();
var message = info.GetArg(1);
if (player == null || !player.IsValid || message.Length == 0 || IsCommandOrTrigger(message)) return HookResult.Continue;
if (player.SteamID.ToString() != "" && GaggedIds.Contains(player.SteamID.ToString())) return HookResult.Handled;
var tag = FindTag(player);
if (tag == null || !tag.AffectsChat) return HookResult.Continue;
if (info.GetArg(1).StartsWith("!") || info.GetArg(1).StartsWith("@") || info.GetArg(1).StartsWith("/") || info.GetArg(1).StartsWith(".") || info.GetArg(1) == "rtv") return HookResult.Continue;
if (JsonTags != null && JsonTags.TryGetValue("tags", out var tags) && tags is JObject tagsObject)
{
string deadIcon = !player.PawnIsAlive ? $"{ChatColors.White}☠ {ChatColors.Default}" : "";
if (tagsObject.TryGetValue(steamid, out var playerTag) && playerTag is JObject)
{
string prefix = playerTag["prefix"]?.ToString() ?? "";
string nickColor = playerTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = playerTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString();
Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum));
return HookResult.Handled;
}
foreach (var tagKey in tagsObject.Properties())
{
if (tagKey.Name.StartsWith("#"))
{
string group = tagKey.Name;
bool inGroup = AdminManager.PlayerInGroup(player, group);
if (inGroup)
{
if (tagsObject.TryGetValue(group, out var groupTag) && groupTag is JObject)
{
string prefix = groupTag["prefix"]?.ToString() ?? "";
string nickColor = groupTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = groupTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString();
Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum));
return HookResult.Handled;
}
}
}
if (tagKey.Name.StartsWith("@"))
{
string permission = tagKey.Name;
bool hasPermission = AdminManager.PlayerHasPermissions(player, permission);
if (hasPermission)
{
if (tagsObject.TryGetValue(permission, out var permissionTag) && permissionTag is JObject)
{
string prefix = permissionTag["prefix"]?.ToString() ?? "";
string nickColor = permissionTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = permissionTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString();
Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum));
return HookResult.Handled;
}
}
}
}
if (tagsObject.TryGetValue("everyone", out var everyoneTag) && everyoneTag is JObject && everyoneTag?["team_chat"]?.Value<bool>() == true)
{
string prefix = everyoneTag["prefix"]?.ToString() ?? "";
string nickColor = everyoneTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = everyoneTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString();
Server.PrintToChatAll(ReplaceTags($" {deadIcon}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", player.TeamNum));
return HookResult.Handled;
}
}
return HookResult.Continue;
var line = FormatChat(player, tag, message, player.TeamNum);
Server.PrintToChatAll($" {line}");
return HookResult.Handled;
}
private HookResult OnPlayerChatTeam(CCSPlayerController? player, CommandInfo info)
{
if (player == null || !player.IsValid || info.GetArg(1).Length == 0 || player.AuthorizedSteamID == null) return HookResult.Continue;
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();
if (player.SteamID.ToString() != "" && GaggedIds.Contains(player.SteamID.ToString())) return HookResult.Handled;
if (info.GetArg(1).StartsWith("@") && AdminManager.PlayerHasPermissions(player, "@css/chat"))
foreach (var (key, tag) in _tags)
{
foreach (var p in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsBot && !p.IsHLTV && AdminManager.PlayerHasPermissions(p, "@css/chat")))
{
p.PrintToChat($" {ChatColors.Lime}(ADMIN) {ChatColors.Default}{player.PlayerName}: {info.GetArg(1).Remove(0, 1)}");
}
return HookResult.Handled;
if (key == steamid) return tag;
}
if (info.GetArg(1).StartsWith("!") || info.GetArg(1).StartsWith("@") || info.GetArg(1).StartsWith("/") || info.GetArg(1).StartsWith(".") || info.GetArg(1) == "rtv") return HookResult.Continue;
if (JsonTags != null && JsonTags.TryGetValue("tags", out var tags) && tags is JObject tagsObject)
foreach (var (key, tag) in _tags)
{
string deadIcon = !player.PawnIsAlive ? $"{ChatColors.White}☠ {ChatColors.Default}" : "";
if (tagsObject.TryGetValue(steamid, out var playerTag) && playerTag is JObject)
{
string prefix = playerTag["prefix"]?.ToString() ?? "";
string nickColor = playerTag?["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = playerTag?["message_color"]?.ToString() ?? ChatColors.Default.ToString();
foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot))
{
string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}";
p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}");
}
return HookResult.Handled;
}
foreach (var tagKey in tagsObject.Properties())
{
if (tagKey.Name.StartsWith("#"))
{
string group = tagKey.Name;
bool inGroup = AdminManager.PlayerInGroup(player, group);
if (inGroup && tagsObject.TryGetValue(group, out var groupTag) && groupTag is JObject)
{
string prefix = groupTag["prefix"]?.ToString() ?? "";
string nickColor = groupTag["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = groupTag["message_color"]?.ToString() ?? ChatColors.Default.ToString();
foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot))
{
string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}";
p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}");
}
return HookResult.Handled;
}
}
if (tagKey.Name.StartsWith("@"))
{
string permission = tagKey.Name;
bool hasPermission = AdminManager.PlayerHasPermissions(player, permission);
if (hasPermission && tagsObject.TryGetValue(permission, out var permissionTag) && permissionTag is JObject)
{
string prefix = permissionTag["prefix"]?.ToString() ?? "";
string nickColor = permissionTag["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = permissionTag["message_color"]?.ToString() ?? ChatColors.Default.ToString();
foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot))
{
string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}";
p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}");
}
return HookResult.Handled;
}
}
}
if (tagsObject.TryGetValue("everyone", out var everyoneTag) && everyoneTag is JObject)
{
string prefix = everyoneTag["prefix"]?.ToString() ?? "";
string nickColor = everyoneTag["nick_color"]?.ToString() ?? ChatColors.Default.ToString();
string messageColor = everyoneTag["message_color"]?.ToString() ?? ChatColors.Default.ToString();
foreach (var p in Utilities.GetPlayers().Where(p => p.TeamNum == player.TeamNum && p.IsValid && !p.IsBot))
{
string messageToSend = $"{deadIcon}{TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}";
p.PrintToChat($" {ReplaceTags(messageToSend, p.TeamNum)}");
}
//p.PrintToChat(ReplaceTags($" {TeamName(player.TeamNum)} {ChatColors.Default}{prefix}{nickColor}{player.PlayerName}{ChatColors.Default}: {messageColor}{info.GetArg(1)}", p.TeamNum));
return HookResult.Handled;
}
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);
}
return HookResult.Continue;
}
private void SetPlayerClanTag(CCSPlayerController? player)
{
if (player == null || !player.IsValid || player.IsBot || player.IsHLTV || player.AuthorizedSteamID == null) return;
if (player == null || !player.IsValid) return;
string steamid = player.SteamID!.ToString();
var tag = FindTag(player);
if (tag == null || tag.Scoreboard == "" || player.Clan == tag.Scoreboard) return;
if (JsonTags != null && JsonTags.TryGetValue("tags", out var tags) && tags is JObject tagsObject)
{
if (tagsObject.TryGetValue(steamid, out var playerTag) && playerTag is JObject)
{
var scoreboardValue = playerTag["scoreboard"]?.ToString();
if (!string.IsNullOrEmpty(scoreboardValue))
{
player.Clan = scoreboardValue;
return;
}
}
foreach (var tagKey in tagsObject.Properties())
{
if (tagKey.Name.StartsWith("#"))
{
string group = tagKey.Name;
bool inGroup = AdminManager.PlayerInGroup(player, group);
if (inGroup)
{
if (tagsObject.TryGetValue(group, out var groupTag) && groupTag is JObject)
{
var scoreboardValue = groupTag["scoreboard"]?.ToString();
if (!string.IsNullOrEmpty(scoreboardValue))
{
player.Clan = scoreboardValue;
return;
}
}
}
}
if (tagKey.Name.StartsWith("@"))
{
string permission = tagKey.Name;
bool hasPermission = AdminManager.PlayerHasPermissions(player, permission);
if (hasPermission)
{
if (tagsObject.TryGetValue(permission, out var permissionTag) && permissionTag is JObject)
{
var scoreboardValue = permissionTag["scoreboard"]?.ToString();
if (!string.IsNullOrEmpty(scoreboardValue))
{
player.Clan = scoreboardValue;
return;
}
}
}
}
}
if (tagsObject.TryGetValue("everyone", out var everyone) && everyone is JObject everyoneObject)
{
var scoreboardValue = everyoneObject["scoreboard"]?.ToString();
if (!string.IsNullOrEmpty(scoreboardValue))
{
player.Clan = scoreboardValue;
}
}
}
player.Clan = tag.Scoreboard;
Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan");
}
private string TeamName(int teamNum)
private static string TeamName(int teamNum)
{
string teamName = "";
switch (teamNum)
return teamNum switch
{
case 0:
teamName = $"(NONE)";
break;
case 1:
teamName = $"(SPEC)";
break;
case 2:
teamName = $"{ChatColors.Yellow}(T)";
break;
case 3:
teamName = $"{ChatColors.Blue}(CT)";
break;
}
return teamName;
(int)CsTeam.Spectator => "(SPEC)",
(int)CsTeam.Terrorist => $"{ChatColors.Yellow}(T)",
(int)CsTeam.CounterTerrorist => $"{ChatColors.Blue}(CT)",
_ => "(NONE)",
};
}
private string TeamColor(int teamNum)
private static string TeamColor(int teamNum)
{
string teamColor;
switch (teamNum)
return teamNum switch
{
case 2:
teamColor = $"{ChatColors.Gold}";
break;
case 3:
teamColor = $"{ChatColors.Blue}";
break;
default:
teamColor = "";
break;
}
return teamColor;
(int)CsTeam.Terrorist => ChatColors.Gold.ToString(),
(int)CsTeam.CounterTerrorist => ChatColors.Blue.ToString(),
_ => "",
};
}
private string ReplaceTags(string message, int teamNum = 0)
private static string ReplaceTags(string text, int teamNum = 0)
{
if (message.Contains('{'))
if (!text.Contains('{')) return text;
foreach (FieldInfo field in typeof(ChatColors).GetFields(BindingFlags.Public | BindingFlags.Static))
{
string modifiedValue = message;
foreach (FieldInfo field in typeof(ChatColors).GetFields())
{
string pattern = $"{{{field.Name}}}";
if (message.Contains(pattern, StringComparison.OrdinalIgnoreCase))
{
modifiedValue = modifiedValue.Replace(pattern, field.GetValue(null)!.ToString(), StringComparison.OrdinalIgnoreCase);
}
}
return modifiedValue.Replace("{TEAMCOLOR}", TeamColor(teamNum));
if (field.FieldType == typeof(char))
text = text.Replace($"{{{field.Name}}}", field.GetValue(null)!.ToString(), StringComparison.OrdinalIgnoreCase);
}
return message;
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": "" }
}
}
""";
}

View file

@ -1,16 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CS2_Tags</RootNamespace>
<AssemblyName>CS2-Tags</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.130" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.375">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -1,22 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CS2-Tags", "CS2-Tags.csproj", "{06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Debug|Any CPU.Build.0 = Debug|Any CPU
{06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Release|Any CPU.ActiveCfg = Release|Any CPU
{06A7649D-5CC9-4A37-9AB8-FEFB5DE69668}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

121
README.md
View file

@ -1,78 +1,75 @@
# CS2-Tags
### Do you appreciate what I do? Buy me a cup of tea ❤️
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y4THKXG)
Our fork of [daffyyyy/CS2-Tags](https://github.com/daffyyyy/CS2-Tags): chat prefixes, name/message
colours and scoreboard (clan) tags, assigned by SteamID64, admin group or permission. Built for
CounterStrikeSharp 1.0.375 (net10.0) and meant to run alongside CS2-SimpleAdmin.
![image](https://github.com/daffyyyy/CS2-Tags/assets/41084667/25dd3f2b-0604-41a2-b2bd-9be230db71e1)
![image](https://github.com/daffyyyy/CS2-Tags/assets/41084667/663a0de1-b875-48fc-bda5-56add5a4833b)
## Changes from upstream 1.0.4c
### Description
Adds tags to the server that can be easily edited, tags can be assigned via permission or steamid64
- Targets net10.0 / CSS 1.0.375. Uses System.Text.Json, so Newtonsoft.Json no longer ships with it.
- Config moved to `configs/plugins/CS2-Tags/tags.json`. The default file uses our SimpleAdmin rank
groups. Comments and trailing commas are allowed. A broken file is logged and the previous
tags stay in use.
- Scoreboard tags call `SetStateChanged` on `m_szClan` so clients are told about the change.
- Colour tags like `{RED}` are only expanded in the config's strings. Players can no longer colour
their own chat by typing them.
- An empty `nick_color` means the team colour (like normal chat), not whatever colour the prefix
ended on.
- A tag with an empty `prefix`, `nick_color` and `message_color` leaves that player's chat alone
(the game prints it), in both all and team chat. The old `team_chat` key is gone.
- `css_tags_reload` works from the server console and for `@css/root` admins, and reapplies
scoreboard tags straight away. Hot reload applies tags to players already connected.
- Removed `css_tag_mute`/`css_tag_unmute` and the `@` team-chat admin chat. CS2-SimpleAdmin
already does both, and its catch-all command listener runs first. CSS always runs those before
per-command listeners, so gagged players' messages never reach this plugin.
### Commands
- css_tags_reload - Reload tags config
## Configuration
### Configuration
```
`addons/counterstrikesharp/configs/plugins/CS2-Tags/tags.json`, written with defaults on first load:
```jsonc
{
"tags": {
"#css/admin": { // Group
"prefix": "{GREEN}[ADMIN]", // Chat prefix
"nick_color": "{RED}", // Nick color
"message_color": "{GOLD}", // Message nick color
"scoreboard": "[ADMIN]" // Scoreboard tag
},
"@css/chat": { // Permission
"prefix": "{GREEN}[ADMIN]", // Chat prefix
"nick_color": "{RED}", // Nick color
"message_color": "{GOLD}", // Message nick color
"scoreboard": "[ADMIN]" // Scoreboard tag
},
"76561198202892670": { // SteamID64
"prefix": "{GREEN}[ADMIN]",
"nick_color": "{RED}",
"message_color": "{GOLD}",
"scoreboard": "[ADMIN]"
},
"everyone": { // Tag for everyone, bots excluded
"prefix": "",
"nick_color": "",
"message_color": "",
"scoreboard": "[Player]"
}
"76561198000000000": { "prefix": "{Magenta}[Dev]", "nick_color": "", "message_color": "", "scoreboard": "[Dev]" },
"#rank/owner": { "prefix": "{DarkRed}[Owner]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Owner]" },
"#rank/admin": { "prefix": "{Red}[Admin]", "nick_color": "{LightRed}", "message_color": "", "scoreboard": "[Admin]" },
"@css/vip": { "prefix": "{Gold}[VIP]", "nick_color": "", "message_color": "", "scoreboard": "[VIP]" },
"everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" }
}
}
```
In addons/counterstrikesharp/plugins/CS2-Tags/tags.json
### Requirments
[CounterStrikeSharp](https://github.com/roflmuffin/CounterStrikeSharp/) **tested on v142**
Each player gets exactly one tag, picked in this order:
1. A SteamID64 key matching the player.
2. The first `#group` or `@permission` key the player matches, **in file order**. Put higher
ranks first. `@css/root` passes every `@css/...` check, so owners match any permission key.
3. `everyone`, if present.
Fields (all optional, empty means "not set"):
- `prefix` - shown before the name in chat.
- `nick_color` / `message_color` - colour tags; empty means team colour / default.
- `scoreboard` - clan tag on the scoreboard; empty leaves the player's own clan tag.
Colours: any `ChatColors` field name in braces, case-insensitive, e.g. `{Red}`, `{LightBlue}`,
`{Gold}`, `{Grey}`, plus `{TEAMCOLOR}`.
Group membership comes from CS2-SimpleAdmin (`css_addadmin ... -g`, `css_addgroup`, then
`css_reloadadmins`). After changing ranks or `tags.json`, run `css_tags_reload` to reapply
scoreboard tags without waiting for a respawn.
## Commands
- `css_tags_reload` (`@css/root` or server console) - reload `tags.json` and reapply scoreboard tags.
## Building and releasing
### Colors
```
public static char Default = '\x01';
public static char White = '\x01';
public static char Darkred = '\x02';
public static char Green = '\x04';
public static char LightYellow = '\x03';
public static char LightBlue = '\x03';
public static char Olive = '\x05';
public static char Lime = '\x06';
public static char Red = '\x07';
public static char Purple = '\x03';
public static char Grey = '\x08';
public static char Yellow = '\x09';
public static char Gold = '\x10';
public static char Silver = '\x0A';
public static char Blue = '\x0B';
public static char DarkBlue = '\x0C';
public static char BlueGrey = '\x0D';
public static char Magenta = '\x0E';
public static char LightRed = '\x0F';
cd plugins/CS2-Tags && ../../build.sh # output in compiled/CS2-Tags/
FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>
```
```{TEAMCOLOR} - Team color```
Use color name for e.g. {LightRed}
### TODO
- Thinking about better fix for commands handling
`release.sh` rebuilds from the committed source and publishes `CS2-Tags-<tag>.tar.gz`, laid out
like `game/csgo/`, to https://git.zio.sh/cs2/CS2-Tags. The tag must equal `v<ModuleVersion>`
(`v1.0.4c-zio1` now).

115
release.sh Executable file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Build CS2-Tags, package it as one tarball and publish it as a Forgejo release.
#
# ./release.sh <tag> e.g. ./release.sh v0.14.0
#
# Commit first: the plugin is rebuilt here from the committed source, so the release always matches
# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with
# the same tag replaces that release's attachment.
#
# Asset: CS2-Tags-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf CS2-Tags-<tag>.tar.gz -C /srv/cs2/game/csgo
#
# addons/counterstrikesharp/plugins/CS2-Tags/ CS2-Tags.dll, .pdb, .deps.json
#
# No configs/: the plugin writes configs/plugins/CS2-Tags/tags.json itself when it is missing, so an
# upgrade never touches the server's tags.
set -euo pipefail
FORGEJO_URL="https://git.zio.sh"
OWNER="cs2"
REPO="CS2-Tags"
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
# (ExcludeAssets=runtime); the server already provides it.
PLUGIN_FILES=(
CS2-Tags.dll
CS2-Tags.pdb
CS2-Tags.deps.json
)
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "usage: $0 <tag> (e.g. v1.0.4c-zio1)" >&2
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
exit 1
fi
for tool in podman jq curl git; do
command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; }
done
cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2
exit 1
fi
# The tag should name the version the plugin reports in `css_plugins list`.
version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' CS2-Tags.cs)"
if [[ "${TAG#v}" != "$version" ]]; then
echo "Tag $TAG doesn't match ModuleVersion $version in CS2-Tags.cs." >&2
exit 1
fi
# --- Build -----------------------------------------------------------------------
OUT="release-out"
PUBLISH="$OUT/publish"
rm -rf bin obj "$OUT"
podman run --rm -v "$(pwd)":/src:Z -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH"
# --- Stage (layout of game/csgo/) and package ----------------------------------------
STAGE="$OUT/stage"
PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/CS2-Tags"
mkdir -p "$PLUGIN_DIR"
for file in "${PLUGIN_FILES[@]}"; do
cp "$PUBLISH/$file" "$PLUGIN_DIR/"
done
TARBALL="CS2-Tags-${TAG}.tar.gz"
tar -czf "$OUT/$TARBALL" -C "$STAGE" addons
rm -rf "$STAGE" "$PUBLISH"
echo "Packaged:"
tar -tzf "$OUT/$TARBALL"
# --- Tag ---------------------------------------------------------------------------
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git tag -a "$TAG" -m "$TAG"
fi
git push origin "refs/tags/$TAG"
# --- Release -----------------------------------------------------------------------
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO"
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)"
if [[ -z "$release_json" ]]; then
body="$(git log -1 --format=%B "$TAG")"
release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' |
curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")"
echo "Created release $TAG"
else
echo "Release $TAG already exists, replacing its attachment"
fi
release_id="$(jq -r '.id' <<<"$release_json")"
existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")"
if [[ -n "$existing_id" ]]; then
curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null
fi
curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \
"$API/releases/$release_id/assets?name=$TARBALL" >/dev/null
echo "Uploaded $TARBALL"
echo
echo "Download URL:"
echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"