Compare commits

...

5 commits

Author SHA1 Message Date
6ffdfbaf9a release.sh: read the Forgejo token from ../../.forgejo-token when FORGEJO_TOKEN isn't set 2026-10-04 18:34:11 +01:00
851397c7d9 1.0.4c-zio7: don't reapply tags in OnAllPluginsLoaded at server start
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.
2026-10-02 18:47:40 +01:00
b0e67b1ee0 1.0.4c-zio6: GroupVerify clan tag for verified players, upper-case rank tags
While GroupVerify has a clan tag set (css_gv_clantag or the web panel), players without a rank
tag get it on the scoreboard if they're verified, and no tag at all if they aren't. Staff keep
their rank tag either way. With no clan tag, or GroupVerify off or missing, nothing changes;
clearing it gives players their own tags back. Tags update as soon as verification or the clan
tag changes (OnVerifiedChanged, OnClanTagChanged). GroupVerifyApi is a compile-only reference
(lib/), optional at runtime.

Also (zio5, not released on its own): the scoreboard spells ranks in capitals (OWNER, S.ADMIN,
ADMIN, ...) and chat prefixes use SimpleAdmin's staff chat rank names ([Mod], [S.Admin], ...).
2026-10-02 18:44:36 +01:00
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
3e652f9f6f 1.0.4c-zio3: spell out ranks on the scoreboard, without brackets
CS2 adds its own brackets around the clan tag, so "[O]" showed as "[[O]]". The default scoreboard
tags are now Owner, Sen.Admin, Admin, T.Admin, Mod, T.Mod, Helper and Guardian. Chat keeps the
short [O]/[M] prefixes.
2026-09-28 20:00:53 +01:00
6 changed files with 222 additions and 33 deletions

View file

@ -27,19 +27,37 @@ public class Tag
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-zio2";
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)
{
@ -49,6 +67,7 @@ public class CS2_Tags : BasePlugin
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn);
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
RegisterListener<Listeners.OnClientDisconnect>(slot => _replaced.Remove(slot));
AddCommandListener("say", OnPlayerChat);
AddCommandListener("say_team", OnPlayerChatTeam);
@ -56,6 +75,32 @@ public class CS2_Tags : BasePlugin
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
@ -68,6 +113,7 @@ public class CS2_Tags : BasePlugin
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)
{
@ -80,6 +126,7 @@ public class CS2_Tags : BasePlugin
}
_tags = tags;
_labels = labels;
Logger.LogInformation("Loaded {Count} tags from {Path}", _tags.Count, TagsPath);
}
catch (Exception ex)
@ -160,7 +207,7 @@ public class CS2_Tags : BasePlugin
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)}";
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}");
@ -171,7 +218,7 @@ public class CS2_Tags : BasePlugin
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 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);
@ -215,26 +262,61 @@ public class CS2_Tags : BasePlugin
private void SetPlayerClanTag(CCSPlayerController? player)
{
if (player == null || !player.IsValid) return;
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);
if (tag == null || tag.Scoreboard == "" || player.Clan == tag.Scoreboard) return;
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);
}
player.Clan = tag.Scoreboard;
if (player.Clan == want) return;
player.Clan = want;
Utilities.SetStateChanged(player, "CCSPlayerController", "m_szClan");
}
private static string TeamName(int teamNum)
// 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 => "(SPEC)",
(int)CsTeam.Terrorist => $"{ChatColors.Yellow}(T)",
(int)CsTeam.CounterTerrorist => $"{ChatColors.Blue}(CT)",
_ => "(NONE)",
(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
@ -260,19 +342,27 @@ public class CS2_Tags : BasePlugin
// 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").
// 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": "[O]" },
"#rank/senioradmin": { "prefix": "{Default}[{Lime}SA{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[SA]" },
"#rank/admin": { "prefix": "{Default}[{Lime}A{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[A]" },
"#rank/trialadmin": { "prefix": "{Default}[{Lime}TA{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[TA]" },
"#rank/mod": { "prefix": "{Default}[{Lime}M{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[M]" },
"#rank/trialmod": { "prefix": "{Default}[{Lime}TM{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[TM]" },
"#rank/helper": { "prefix": "{Default}[{Lime}H{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[H]" },
"#rank/guardian": { "prefix": "{Default}[{Lime}G{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[G]" },
"everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" }
"#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": "" }
}
}
""";

View file

@ -15,6 +15,12 @@
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Compile-time only: GroupVerify installs it in shared/GroupVerifyApi, copied from
plugins/GroupVerify's build. Optional at runtime: without it, tags work as before. -->
<Reference Include="GroupVerifyApi">
<HintPath>lib/GroupVerifyApi.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

View file

@ -19,6 +19,7 @@ CounterStrikeSharp 1.0.375 (net10.0) and meant to run alongside CS2-SimpleAdmin.
(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.
- Verification tags from GroupVerify (see below).
- 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.
@ -26,17 +27,19 @@ CounterStrikeSharp 1.0.375 (net10.0) and meant to run alongside CS2-SimpleAdmin.
## Configuration
`addons/counterstrikesharp/configs/plugins/CS2-Tags/tags.json`, written on first load with a tag
for each of our SimpleAdmin rank groups. They use the same abbreviations as SimpleAdmin's staff chat
(O, SA, A, TA, M, TM, H, G): `[M] name: message` in chat, with a white `[ ]`, lime rank and
team-coloured name, and `[M]` on the scoreboard. An example with other kinds of key:
for each of our SimpleAdmin rank groups. The chat prefixes are the same rank tags SimpleAdmin uses in
staff chat (O, S.Admin, Admin, T.Admin, Mod, T.Mod, Helper, Guardian): `[Mod] name: message`,
with a white `[ ]`, lime rank and team-coloured name. The scoreboard shows OWNER, S.ADMIN, ADMIN,
T.ADMIN, MOD, T.MOD, HELPER and GUARDIAN (CS2 adds the brackets itself). An example with other
kinds of key:
```jsonc
{
"tags": {
"76561198000000000": { "prefix": "{Magenta}[Dev]", "nick_color": "", "message_color": "", "scoreboard": "[Dev]" },
"#rank/owner": { "prefix": "{Default}[{Lime}O{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[O]" },
"#rank/mod": { "prefix": "{Default}[{Lime}M{Default}]", "nick_color": "", "message_color": "", "scoreboard": "[M]" },
"@css/vip": { "prefix": "{Gold}[VIP]", "nick_color": "", "message_color": "", "scoreboard": "[VIP]" },
"76561198000000000": { "prefix": "{Magenta}[Dev]", "nick_color": "", "message_color": "", "scoreboard": "Dev" },
"#rank/owner": { "prefix": "{Default}[{Lime}O{Default}]", "nick_color": "", "message_color": "", "scoreboard": "OWNER" },
"#rank/mod": { "prefix": "{Default}[{Lime}Mod{Default}]", "nick_color": "", "message_color": "", "scoreboard": "MOD" },
"@css/vip": { "prefix": "{Gold}[VIP]", "nick_color": "", "message_color": "", "scoreboard": "VIP" },
"everyone": { "prefix": "", "nick_color": "", "message_color": "", "scoreboard": "" }
}
}
@ -53,7 +56,22 @@ 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.
- `scoreboard` - clan tag on the scoreboard, without brackets (CS2 adds them); empty leaves the
player's own clan tag.
The optional `chat` section sets the labels CS2-Tags adds to the chat lines it prints (only for
players whose tag changes their chat). Leave a key out for its default, or set it to `""` to drop
the label:
```jsonc
"chat": {
"dead": "{White}☠", // before a dead player's line
"team_t": "{Yellow}(T)", // start of team chat, per team
"team_ct": "{Blue}(CT)",
"team_spec": "(SPEC)",
"team_none": "(NONE)"
}
```
Colours: any `ChatColors` field name in braces, case-insensitive, e.g. `{Red}`, `{LightBlue}`,
`{Gold}`, `{Grey}`, plus `{TEAMCOLOR}`.
@ -62,6 +80,25 @@ Group membership comes from CS2-SimpleAdmin (`css_addadmin ... -g`, `css_addgrou
`css_reloadadmins`). After changing ranks or `tags.json`, run `css_tags_reload` to reapply
scoreboard tags without waiting for a respawn.
## Verified players' clan tag (GroupVerify)
When GroupVerify (`plugins/GroupVerify`) is installed and has a clan tag set (`css_gv_clantag`,
or the web panel's Verification page), players **without a rank tag** get it in place of their own
Steam group tag:
| | verified | not verified |
|-----------------------------------|----------------|----------------|
| a scoreboard tag from `tags.json` | their rank tag | their rank tag |
| everyone else | the clan tag | no tag at all |
Staff keep their rank tag either way. With no clan tag set, GroupVerify turned off or not
installed, everyone else keeps their own tag, as before; turning the clan tag off puts back the
tag each player had. Tags update as soon as someone's verification or the clan tag changes.
GroupVerify's API is a compile-only reference (`lib/GroupVerifyApi.dll`, a copy of
`plugins/GroupVerify`'s build; GroupVerify installs the real one in `shared/GroupVerifyApi`).
CS2-Tags loads without it.
## Commands
- `css_tags_reload` (`@css/root` or server console) - reload `tags.json` and reapply scoreboard tags.
@ -75,4 +112,4 @@ FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>
`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-zio2` now).
(`v1.0.4c-zio7` now).

51
VerifyLink.cs Normal file
View file

@ -0,0 +1,51 @@
using CounterStrikeSharp.API.Core;
using GroupVerifyApi;
namespace CS2_Tags;
// zio: GroupVerify's API (shared/GroupVerifyApi). Kept in its own class, and only created inside a
// try in OnAllPluginsLoaded, so CS2-Tags still loads, and tags as before, when GroupVerify isn't
// installed and its assembly can't be found.
internal sealed class VerifyLink : IDisposable
{
private readonly IGroupVerifyApi _api;
private readonly Action<CCSPlayerController> _playerChanged;
private readonly Action _tagChanged;
private VerifyLink(IGroupVerifyApi api, Action<CCSPlayerController> playerChanged, Action tagChanged)
{
_api = api;
_playerChanged = playerChanged;
_tagChanged = tagChanged;
_api.OnVerifiedChanged += OnVerifiedChanged;
_api.OnClanTagChanged += _tagChanged;
}
// The API, or null when GroupVerify isn't loaded.
public static VerifyLink? TryCreate(Action<CCSPlayerController> playerChanged, Action tagChanged)
{
IGroupVerifyApi? api;
try
{
api = IGroupVerifyApi.Capability.Get();
}
catch (KeyNotFoundException)
{
return null;
}
return api == null ? null : new VerifyLink(api, playerChanged, tagChanged);
}
// The tag verified players without a rank get, "" while none is set or GroupVerify is off.
public string ClanTag => _api.ClanTag;
public bool IsVerified(CCSPlayerController player) => _api.IsVerified(player);
private void OnVerifiedChanged(CCSPlayerController player, VerifyStatus status) => _playerChanged(player);
public void Dispose()
{
_api.OnVerifiedChanged -= OnVerifiedChanged;
_api.OnClanTagChanged -= _tagChanged;
}
}

BIN
lib/GroupVerifyApi.dll Normal file

Binary file not shown.

View file

@ -19,8 +19,13 @@ 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.
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their
# repos, mode 600). Don't commit a real token.
TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token"
if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then
FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
fi
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
@ -37,7 +42,7 @@ if [[ -z "$TAG" ]]; then
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2
exit 1
fi
for tool in podman jq curl git; do