Compare commits

...

12 commits

Author SHA1 Message Date
4420d41048 zio fork 1.8.2b-zio17: no startup version check or banner
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled
Removes upstream's PluginInfo: CheckVersion, which fetched upstream's
VERSION from GitHub on every load, and ShowAd, the console banner with
two encoded lines of personal messages. EnableUpdateCheck stays in the
config so existing files load, and does nothing.
2026-10-07 09:47:58 +01:00
1a1253b865 release.sh: read the Forgejo token from ../../.forgejo-token when FORGEJO_TOKEN isn't set
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled
2026-10-04 18:34:10 +01:00
71d51d7b92 zio fork 1.8.2b-zio16: backport unban, rename and css_history from upstream 1.9.0
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled
From upstream 530f603:
- css_unban updates the ban cache at once (and clears the engine banid with
  UnlockedCommands) instead of waiting up to a minute for the cache refresh.
- css_prename is saved in sa_renames (migration 017) and re-applied on connect and
  name change, so it survives restarts. css_rename from a hidden admin renames again.
- New css_history command and Players > History menu entry. Commands missing from an
  existing Commands.json are registered under their default name.
- Migrations are copied with a glob, so 017 reaches the build output.

Upstream's admin-loading changes, css_say change and css_penalties fix are left out.
2026-10-03 12:08:45 +01:00
8bec658db4 Targets: prefer an exact name, partial names for ungag/unmute/unsilence
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
Part of 1.8.2b-zio15.
2026-10-03 00:18:04 +01:00
dc5ad30952 zio fork 1.8.2b-zio15: in-game gags/mutes/silences expire with TimeMode 0
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
The in-memory penalty was only cleared when its end time exactly matched the
database row's, which never happens for one given in game (sub-second vs
to-the-second), so it lasted until reconnect. Match by type within 5 seconds,
and stop applying a penalty as soon as it's marked passed.
2026-10-03 00:15:03 +01:00
7b3d0ed452 zio fork 1.8.2b-zio14: [ALL] tag on staff all-chat (sa_staffsay_template)
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
2026-10-03 00:07:20 +01:00
bbb72757e7 zio fork 1.8.2b-zio13: no double space in console css_say/css_asay/css_psay
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
The message is taken from the first space in the command string, so it starts
with a space; the player paths trimmed it, the console paths didn't.
2026-10-02 21:47:48 +01:00
395e513f44 zio fork 1.8.2b-zio12: restyle lang/en.json
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
[zio] prefix, red admin and yellow player names in announcements, two-line
gag/silence/ban notices with the reason on its own line, [PM] for private
messages, and no {default} at the start or end of messages (sa_prefix keeps
it). The bring message says "to themselves".
2026-10-02 21:44:01 +01:00
d042aab1c0 zio fork 1.8.2b-zio11: reloading admins removes deleted and demoted admins
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
CSS's admin loaders only add, and the per-admin cleanup in CreateAdminsJsonFile
reset AdminCache once per admin, so it only ever tracked the last one and a
reload never took anyone else out of memory. ReloadAdmins now removes every
admin it loaded last time and loads the new set in one tick. admins.json is
keyed by SteamID instead of name, so admins sharing a name aren't merged, and a
failed admins query keeps the loaded admins instead of removing them.
2026-10-02 21:21:09 +01:00
f46ccf18ce zio fork 1.8.2b-zio10: rank tags match CS2-Tags, log rank changes to sa_rank_log
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled
2026-09-29 16:56:38 +01:00
cf1090cbd2 zio fork 1.8.2b-zio9: admin menu honours command overrides, @zio/penalties
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
Every player and server entry in the admin menu names its command, and an overridden entry is
checked with the override's check_type ("any" passes with one flag), the way CSS checks the typed
command; upstream required every flag. css_penalties for someone else needs @zio/penalties or
@css/root instead of @css/kick.
2026-09-29 12:44:15 +01:00
65a413fa52 zio fork 1.8.2b-zio8: staff all-chat line with a lime name and gold message
Some checks are pending
Build and Publish / build (push) Waiting to run
Build and Publish / publish (push) Blocked by required conditions
2026-09-28 19:56:25 +01:00
43 changed files with 895 additions and 424 deletions

1
.gitignore vendored
View file

@ -14,3 +14,4 @@ CLAUDE.md
/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN /Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN
release-out/ release-out/
.claude/

View file

@ -313,6 +313,36 @@ css_warns PlayerName
--- ---
### Show Penalty History
Print every past and current penalty for a player to your console.
```bash
css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns]
```
**Permission:** `@css/kick`
**Shows:**
- Bans, gags, mutes, silences and warns, newest first
- Status (active, expired, unbanned, unmuted), duration and reason
- Admin who issued it
- Who lifted it, when and why, for unbans and unmutes
**Notes:**
- Accepts a SteamID64, so it works for players who are not online
- The optional second argument narrows the list to one penalty type
- Also available in the admin menu under Players > History
**Examples:**
```bash
css_history #123
css_history PlayerName bans
css_history 76561198012345678
```
---
### Show Online Players ### Show Online Players
Show information about all online players. Show information about all online players.

View file

@ -251,9 +251,9 @@ css_prename PlayerName "NewIdentity"
``` ```
**Notes:** **Notes:**
- Name is enforced even after reconnect - Name is enforced even after reconnect, map change and server restart
- Stored in database - Stored in the database (`sa_renames`) and shared by every server using that database
- Player cannot change it - Player cannot change it; `css_prename <player>` with no name removes the rename
- Useful for offensive names - Useful for offensive names
--- ---

View file

@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
public override string ModuleAuthor => "daffyy"; public override string ModuleAuthor => "daffyy";
public override string ModuleVersion => "1.8.2b-zio7"; public override string ModuleVersion => "1.8.2b-zio17";
public override void Load(bool hotReload) public override void Load(bool hotReload)
{ {
@ -217,8 +217,8 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
if (!string.IsNullOrEmpty(Config.Discord.DiscordLogWebhook)) if (!string.IsNullOrEmpty(Config.Discord.DiscordLogWebhook))
DiscordWebhookClientLog = new DiscordManager(Config.Discord.DiscordLogWebhook); DiscordWebhookClientLog = new DiscordManager(Config.Discord.DiscordLogWebhook);
if (Config.EnableUpdateCheck) // zio: no update check or startup banner (upstream's PluginInfo, removed). EnableUpdateCheck
Task.Run(async () => await PluginInfo.CheckVersion(ModuleVersion, Logger)); // is left in the config so existing files still load, and does nothing.
PermissionManager = new PermissionManager(DatabaseProvider); PermissionManager = new PermissionManager(DatabaseProvider);
BanManager = new BanManager(DatabaseProvider); BanManager = new BanManager(DatabaseProvider);
@ -242,7 +242,13 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
if (matches.Count() == 1) if (matches.Count() == 1)
return matches; return matches;
command.ReplyToCommand($"Multiple targets found for \"{command.GetArg(argument)}\"."); // zio: a name that is exactly the pattern wins over names that only contain it, so "duck"
// still finds "Duck" when "Ducky" is also on.
var exact = matches.Players.Where(p => p.PlayerName.Equals(command.GetArg(argument), StringComparison.OrdinalIgnoreCase)).ToList();
if (exact.Count == 1)
return new TargetResult { Players = exact };
command.ReplyToCommand($"Multiple targets found for \"{command.GetArg(argument)}\": {string.Join(", ", matches.Players.Select(p => p.PlayerName))}.");
return null; return null;
} }

View file

@ -36,106 +36,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="lang\**\*.*" CopyToOutputDirectory="PreserveNewest" /> <None Update="lang\**\*.*" CopyToOutputDirectory="PreserveNewest" />
<None Update="Database\Migrations\Mysql\001_CreateTables.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\002_CreateFlagsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\003_ChangeColumnsPosition.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\004_MoveOldFlagsToFlagsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\005_CreateUnbansTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\006_ServerGroupsFeature.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\007_ServerGroupsGlobal.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\008_OnlineTimeInPenalties.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\009_BanAllUsedIpAddress.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\010_CreateWarnsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\011_AddRconColumnToServersTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\012_AddUpdatedAtColumnToSaBansTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\013_AddNameColumnToSaPlayerIpsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\014_AddIndexesToMutesTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\015_steamidToBigInt.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Mysql\016_OptimizeTablesAndIndexes.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\001_CreateTables.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\002_CreateFlagsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\003_ChangeColumnsPosition.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\004_MoveOldFlagsToFlagsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\005_CreateUnbansTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\006_ServerGroupsFeature.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\007_ServerGroupsGlobal.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\008_OnlineTimeInPenalties.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\009_BanAllUsedIpAddress.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\010_CreateWarnsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\011_AddRconColumnToServersTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\012_AddUpdatedAtColumnToSaBansTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\013_AddNameColumnToSaPlayerIpsTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\014_AddIndexesToMutesTable.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\015_steamidToBigInt.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Database\Migrations\Sqlite\016_OptimizeTablesAndIndexes.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="Database\Migrations\*.sql" CopyToOutputDirectory="PreserveNewest" /> <None Update="Database\Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -45,6 +45,7 @@ public static class RegisterCommands
new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand), new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand),
new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand), new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand),
new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand), new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand),
new("css_history", CS2_SimpleAdmin.Instance.OnHistoryCommand),
new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand), new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand),
new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand), new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand),
new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand), new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand),
@ -130,6 +131,7 @@ public static class RegisterCommands
{ "css_who", new Command { Aliases = ["css_who"] } }, { "css_who", new Command { Aliases = ["css_who"] } },
{ "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } }, { "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } },
{ "css_warns", new Command { Aliases = ["css_warns"] } }, { "css_warns", new Command { Aliases = ["css_warns"] } },
{ "css_history", new Command { Aliases = ["css_history", "css_penaltyhistory"] } },
{ "css_players", new Command { Aliases = ["css_players"] } }, { "css_players", new Command { Aliases = ["css_players"] } },
{ "css_kick", new Command { Aliases = ["css_kick"] } }, { "css_kick", new Command { Aliases = ["css_kick"] } },
{ "css_map", new Command { Aliases = ["css_map", "css_changemap"] } }, { "css_map", new Command { Aliases = ["css_map", "css_changemap"] } },
@ -198,6 +200,13 @@ public static class RegisterCommands
CS2_SimpleAdmin.Instance.AddCommand(alias, "", mapping.Callback); CS2_SimpleAdmin.Instance.AddCommand(alias, "", mapping.Callback);
} }
} }
// Commands added after Commands.json was generated are registered under their default name
foreach (var mapping in CommandMappings.Where(m => !commandsConfig.Commands.ContainsKey(m.CommandKey)))
{
CS2_SimpleAdmin._logger?.LogInformation($"Registering command: `{mapping.CommandKey}` (not in Commands.json, using default alias)");
CS2_SimpleAdmin.Instance.AddCommand(mapping.CommandKey, "", mapping.Callback);
}
} }
foreach (var (name, definitions) in _commandDefinitions) foreach (var (name, definitions) in _commandDefinitions)

View file

@ -32,7 +32,7 @@ public partial class CS2_SimpleAdmin
if (_localizer == null) continue; if (_localizer == null) continue;
// zio: players get ">[STAFF][RANK] name: message", like @ in team chat. // zio: players get ">[STAFF][RANK] name: message", like @ in team chat.
player.PrintToChat(caller == null player.PrintToChat(caller == null
? _localizer["sa_adminchat_template_admin", _localizer["sa_console"], utf8String] ? _localizer["sa_adminchat_template_admin", _localizer["sa_console"], utf8String.Trim()]
: StaffChatMessage(caller, utf8String.Trim())); : StaffChatMessage(caller, utf8String.Trim()));
} }
} }
@ -101,7 +101,7 @@ public partial class CS2_SimpleAdmin
{ {
player.SendLocalizedMessage(_localizer, player.SendLocalizedMessage(_localizer,
"sa_adminsay_prefix", "sa_adminsay_prefix",
utf8String.ReplaceColorTags()); utf8String.Trim().ReplaceColorTags()); // zio: Trim, the message starts with the space after the command
} }
} }
@ -131,7 +131,7 @@ public partial class CS2_SimpleAdmin
// zio: from a player, "(PM) [RANK] name: message". // zio: from a player, "(PM) [RANK] name: message".
var rank = caller == null ? null : Helper.GetStaffRankTag(caller); var rank = caller == null ? null : Helper.GetStaffRankTag(caller);
var line = caller == null || _localizer == null var line = caller == null || _localizer == null
? $"({callerName}) {utf8String}".ReplaceColorTags() ? $"({callerName}) {utf8String.Trim()}".ReplaceColorTags()
: rank == null : rank == null
? _localizer["sa_psay_template_norank", callerName, utf8String.Trim()] ? _localizer["sa_psay_template_norank", callerName, utf8String.Trim()]
: _localizer["sa_psay_template", rank, callerName, utf8String.Trim()]; : _localizer["sa_psay_template", rank, callerName, utf8String.Trim()];

View file

@ -39,7 +39,9 @@ public partial class CS2_SimpleAdmin
var userId = caller.UserId.Value; var userId = caller.UserId.Value;
var steamId = caller.SteamID; var steamId = caller.SteamID;
if (!string.IsNullOrEmpty(command.GetArg(1)) && AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@css/kick")) // zio: other players' penalties need @zio/penalties (or root), not @css/kick.
if (!string.IsNullOrEmpty(command.GetArg(1)) && (AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@zio/penalties")
|| AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@css/root")))
{ {
var targets = GetTarget(command); var targets = GetTarget(command);
@ -284,7 +286,9 @@ public partial class CS2_SimpleAdmin
if (DatabaseProvider == null) return; if (DatabaseProvider == null) return;
var flagsList = flags.Split(',').Select(flag => flag.Trim()).ToList(); var flagsList = flags.Split(',').Select(flag => flag.Trim()).ToList();
_ = Instance.PermissionManager.AddAdminBySteamId(steamid, name, flagsList, immunity, time, globalAdmin); // zio: who did it, for the rank log (null caller is Console, which includes RCON).
_ = Instance.PermissionManager.AddAdminBySteamId(steamid, name, flagsList, immunity, time, globalAdmin,
caller?.SteamID ?? 0, caller?.PlayerName ?? "Console");
Helper.LogCommand(caller, $"css_addadmin {steamid} {name} {flags} {immunity} {time}"); Helper.LogCommand(caller, $"css_addadmin {steamid} {name} {flags} {immunity} {time}");
@ -329,7 +333,8 @@ public partial class CS2_SimpleAdmin
public void RemoveAdmin(CCSPlayerController? caller, string steamid, bool globalDelete = false, CommandInfo? command = null) public void RemoveAdmin(CCSPlayerController? caller, string steamid, bool globalDelete = false, CommandInfo? command = null)
{ {
if (DatabaseProvider == null) return; if (DatabaseProvider == null) return;
_ = PermissionManager.DeleteAdminBySteamId(steamid, globalDelete); // zio: who did it, for the rank log (null caller is Console, which includes RCON).
_ = PermissionManager.DeleteAdminBySteamId(steamid, globalDelete, caller?.SteamID ?? 0, caller?.PlayerName ?? "Console");
AddTimer(2, () => AddTimer(2, () =>
{ {
@ -502,24 +507,29 @@ public partial class CS2_SimpleAdmin
Task.Run(async () => Task.Run(async () =>
{ {
await PermissionManager.CreateGroupsJsonFile(); await PermissionManager.CreateGroupsJsonFile();
await PermissionManager.CreateAdminsJsonFile(); var admins = await PermissionManager.CreateAdminsJsonFile();
if (admins == null) return;
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
AddTimer(1, () => // zio: CSS's loaders only ever add, so first remove everyone loaded last time (removed or
{ // demoted admins kept their old rank until a restart otherwise), then load the new set,
if (!string.IsNullOrEmpty(adminsFile)) // all in one tick so nobody is left without their flags in between. Groups can't be
AddTimer(2.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json")); // removed from CSS, so flags taken off a group still need a restart.
if (!string.IsNullOrEmpty(groupsFile)) foreach (var steamId in PermissionManager.AdminCache.Keys.ToList())
AddTimer(3.0f, () => AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json")); AdminManager.RemovePlayerAdminData(steamId);
if (!string.IsNullOrEmpty(adminsFile)) PermissionManager.AdminCache.Clear();
AddTimer(4.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
_logger?.LogInformation("Loaded admins!"); foreach (var admin in admins.GroupBy(a => a.SteamId))
}); {
var ends = admin.Any(a => a.Ends == null) ? null : admin.Max(a => a.Ends);
PermissionManager.AdminCache[admin.Key] = (ends, admin.SelectMany(a => a.Flags).Distinct().ToList());
}
AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json");
AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json");
_logger?.LogInformation("Loaded {count} admins!", PermissionManager.AdminCache.Count);
}); });
}); });
@ -836,6 +846,99 @@ public partial class CS2_SimpleAdmin
}); });
} }
/// <summary>
/// Prints a player's full penalty history (bans, gags, mutes, silences, warns) to the caller's console.
/// Accepts a SteamID64 so it also works for players who are not online.
/// </summary>
/// <param name="caller">The player issuing the command or null for console.</param>
/// <param name="command">The command containing the target and an optional penalty type filter.</param>
[RequiresPermissions("@css/kick")]
[CommandHelper(minArgs: 1, usage: "<#userid or name or steamid64> [bans|gags|mutes|silences|warns]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
public void OnHistoryCommand(CCSPlayerController? caller, CommandInfo command)
{
if (DatabaseProvider == null || _localizer == null) return;
var filter = command.ArgCount > 2 ? command.GetArg(2).ToLowerInvariant() : null;
if (filter is not (null or "bans" or "gags" or "mutes" or "silences" or "warns"))
{
command.ReplyToCommand("Usage: css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns]");
return;
}
ulong steamId;
string? name = null;
if (Helper.ValidateSteamId(command.GetArg(1), out var parsedSteamId) && parsedSteamId != null)
{
if (caller != null && !caller.CanTarget(parsedSteamId)) return;
steamId = parsedSteamId.SteamId64;
if (PlayersInfo.TryGetValue(steamId, out var info)) name = info.Name;
}
else
{
var targets = GetTarget(command);
if (targets == null) return;
var players = targets.Players.Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false }).ToList();
if (players.Count != 1)
{
command.ReplyToCommand("Only one target is allowed.");
return;
}
var player = players[0];
if (caller != null && !caller.CanTarget(player)) return;
steamId = player.SteamID;
name = player.PlayerName;
}
Helper.LogCommand(caller, command);
Action<string> print = caller == null ? Server.PrintToConsole : caller.PrintToConsole;
var callerSteamId = caller?.SteamID;
Task.Run(async () =>
{
var rows = await PlayerManager.GetPenaltyHistory(steamId, filter);
// Offline players are only known by SteamID; borrow the name stored on their latest record
name ??= rows.Count > 0 ? (string?)rows[0].player_name : null;
await Server.NextWorldUpdateAsync(() =>
{
// The slot may have been reused by another player while the query ran
if (caller != null && (!caller.IsValid || caller.SteamID != callerSteamId)) return;
PrintHistory(print, name ?? steamId.ToString(), steamId, rows);
});
});
}
/// <summary>
/// Writes history rows from <see cref="PlayerManager.GetPenaltyHistory"/> as one console line each.
/// </summary>
internal void PrintHistory(Action<string> print, string name, ulong steamId, List<dynamic> rows)
{
if (_localizer == null) return;
if (rows.Count == 0)
{
print(_localizer["sa_history_none", name]);
return;
}
print(_localizer["sa_history_header", name, steamId, rows.Count]);
foreach (var r in rows)
{
var duration = (int)r.duration == 0 ? "perm" : $"{(int)r.duration}m";
var line = $"[{Date(r.created)}] {(string)r.type,-7} {(string)r.status,-8} {duration,-6} by {(string)r.admin_name}: {(string)r.reason}";
if (r.lift_date != null)
line += $" | lifted {Date(r.lift_date)} by {(string?)r.lift_admin ?? "?"}: {(string?)r.lift_reason}";
print(line);
}
static string Date(object? value) => PlayerManager.ToDateTime(value)?.ToString("yyyy-MM-dd HH:mm") ?? "?";
}
/// <summary> /// <summary>
/// Lists players currently connected to the server with options to output JSON or filter duplicate IPs. /// Lists players currently connected to the server with options to output JSON or filter duplicate IPs.
/// </summary> /// </summary>

View file

@ -292,6 +292,13 @@ public partial class CS2_SimpleAdmin
var nameMatches = Helper.GetPlayerFromName(pattern); var nameMatches = Helper.GetPlayerFromName(pattern);
var namePlayer = nameMatches.Count == 1 ? nameMatches.FirstOrDefault() : null; var namePlayer = nameMatches.Count == 1 ? nameMatches.FirstOrDefault() : null;
// zio: several online players match part of the name; don't fall through to the offline search.
if (nameMatches.Count > 1)
{
command.ReplyToCommand($"Multiple targets found for \"{pattern}\": {string.Join(", ", nameMatches.Select(p => p.PlayerName))}.");
return;
}
if (namePlayer != null && namePlayer.IsValid) if (namePlayer != null && namePlayer.IsValid)
{ {
PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Gag); PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Gag);
@ -604,6 +611,13 @@ public partial class CS2_SimpleAdmin
var nameMatches = Helper.GetPlayerFromName(pattern); var nameMatches = Helper.GetPlayerFromName(pattern);
var namePlayer = nameMatches.Count == 1 ? nameMatches.FirstOrDefault() : null; var namePlayer = nameMatches.Count == 1 ? nameMatches.FirstOrDefault() : null;
// zio: several online players match part of the name; don't fall through to the offline search.
if (nameMatches.Count > 1)
{
command.ReplyToCommand($"Multiple targets found for \"{pattern}\": {string.Join(", ", nameMatches.Select(p => p.PlayerName))}.");
return;
}
if (namePlayer != null && namePlayer.IsValid) if (namePlayer != null && namePlayer.IsValid)
{ {
PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Mute); PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Mute);
@ -919,6 +933,13 @@ public partial class CS2_SimpleAdmin
var nameMatches = Helper.GetPlayerFromName(pattern); var nameMatches = Helper.GetPlayerFromName(pattern);
var namePlayer = nameMatches.Count == 1 ? nameMatches.FirstOrDefault() : null; var namePlayer = nameMatches.Count == 1 ? nameMatches.FirstOrDefault() : null;
// zio: several online players match part of the name; don't fall through to the offline search.
if (nameMatches.Count > 1)
{
command.ReplyToCommand($"Multiple targets found for \"{pattern}\": {string.Join(", ", nameMatches.Select(p => p.PlayerName))}.");
return;
}
if (namePlayer != null && namePlayer.IsValid) if (namePlayer != null && namePlayer.IsValid)
{ {
PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Silence); PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Silence);

View file

@ -292,8 +292,7 @@ public partial class CS2_SimpleAdmin
var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName }; var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName };
// Display admin activity message to other players // Display admin activity message to other players
if (caller != null && SilentPlayers.Contains(caller.Slot)) return; if (caller != null && !SilentPlayers.Contains(caller.Slot))
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs); Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
// Rename the player // Rename the player
@ -344,15 +343,18 @@ public partial class CS2_SimpleAdmin
} }
// Determine if the new name is valid and update the renamed players list // Determine if the new name is valid and update the renamed players list
var steamId = player.SteamID;
if (!string.IsNullOrEmpty(newName)) if (!string.IsNullOrEmpty(newName))
{ {
RenamedPlayers[player.SteamID] = newName; RenamedPlayers[steamId] = newName;
player.Rename(newName); player.Rename(newName);
} }
else else
{ {
RenamedPlayers.Remove(player.SteamID); RenamedPlayers.Remove(steamId);
} }
Task.Run(() => PlayerManager.SaveRenamedPlayer(steamId, newName));
}); });
} }

View file

@ -42,6 +42,11 @@ public interface IDatabaseProvider
string GetExpireIpBansQuery(bool multiServer); string GetExpireIpBansQuery(bool multiServer);
string GetExpireOldPlayerIpsQuery(); string GetExpireOldPlayerIpsQuery();
// Renames (css_prename)
string GetRenamesQuery();
string GetUpsertRenameQuery();
string GetDeleteRenameQuery();
// MuteManager // MuteManager
string GetAddMuteQuery(bool includePlayerName); string GetAddMuteQuery(bool includePlayerName);
string GetIsMutedQuery(bool multiServer, int timeMode); string GetIsMutedQuery(bool multiServer, int timeMode);
@ -61,4 +66,7 @@ public interface IDatabaseProvider
string GetUnwarnByIdQuery(bool multiServer); string GetUnwarnByIdQuery(bool multiServer);
string GetUnwarnLastQuery(bool multiServer); string GetUnwarnLastQuery(bool multiServer);
string GetExpireWarnsQuery(bool multiServer); string GetExpireWarnsQuery(bool multiServer);
// Penalty history (css_history)
string GetPenaltyHistoryQuery(bool multiServer);
} }

View file

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS `sa_renames` (
`player_steamid` BIGINT NOT NULL,
`name` VARCHAR(128) NOT NULL,
PRIMARY KEY (`player_steamid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View file

@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS `sa_renames` (
`player_steamid` INTEGER NOT NULL PRIMARY KEY,
`name` TEXT NOT NULL
);

View file

@ -217,8 +217,8 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
public string GetUnbanRetrieveBansQuery(bool multiServer) public string GetUnbanRetrieveBansQuery(bool multiServer)
{ {
return multiServer return multiServer
? "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'" ? "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'"
: "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid"; : "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid";
} }
public string GetUnbanAdminIdQuery() public string GetUnbanAdminIdQuery()
@ -238,6 +238,15 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
return "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId"; return "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId";
} }
public string GetRenamesQuery() =>
"SELECT player_steamid, name FROM sa_renames";
public string GetUpsertRenameQuery() =>
"INSERT INTO sa_renames (player_steamid, name) VALUES (@steamId, @name) ON DUPLICATE KEY UPDATE name = @name";
public string GetDeleteRenameQuery() =>
"DELETE FROM sa_renames WHERE player_steamid = @steamId";
public string GetExpireBansQuery(bool multiServer) public string GetExpireBansQuery(bool multiServer)
{ {
return multiServer return multiServer
@ -404,6 +413,29 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime" ? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime"
: "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime AND server_id = @serverid"; : "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime AND server_id = @serverid";
public string GetPenaltyHistoryQuery(bool multiServer) =>
$"""
SELECT b.id, 'BAN' AS type, b.player_name, b.admin_name, b.reason, b.duration, b.created, b.ends, b.status,
ub.reason AS lift_reason, ub.date AS lift_date, ua.player_name AS lift_admin
FROM sa_bans b
LEFT JOIN sa_unbans ub ON ub.id = b.unban_id
LEFT JOIN sa_admins ua ON ua.id = ub.admin_id
WHERE b.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND b.server_id = @serverid")}
UNION ALL
SELECT m.id, m.type, m.player_name, m.admin_name, m.reason, m.duration, m.created, m.ends, m.status,
um.reason AS lift_reason, um.date AS lift_date, ua.player_name AS lift_admin
FROM sa_mutes m
LEFT JOIN sa_unmutes um ON um.id = m.unmute_id
LEFT JOIN sa_admins ua ON ua.id = um.admin_id
WHERE m.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND m.server_id = @serverid")}
UNION ALL
SELECT w.id, 'WARN' AS type, w.player_name, w.admin_name, w.reason, w.duration, w.created, w.ends, w.status,
NULL AS lift_reason, NULL AS lift_date, NULL AS lift_admin
FROM sa_warns w
WHERE w.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND w.server_id = @serverid")}
ORDER BY created DESC
""";
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }

View file

@ -143,8 +143,8 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetUnbanRetrieveBansQuery(bool multiServer) => public string GetUnbanRetrieveBansQuery(bool multiServer) =>
multiServer multiServer
? "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'" ? "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'"
: "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid"; : "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid";
public string GetUnbanAdminIdQuery() => public string GetUnbanAdminIdQuery() =>
"SELECT id FROM sa_admins WHERE player_steamid = @adminSteamId"; "SELECT id FROM sa_admins WHERE player_steamid = @adminSteamId";
@ -154,13 +154,14 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
? "INSERT INTO sa_unbans (ban_id, admin_id, reason) VALUES (@banId, @adminId, @reason); SELECT last_insert_rowid();" ? "INSERT INTO sa_unbans (ban_id, admin_id, reason) VALUES (@banId, @adminId, @reason); SELECT last_insert_rowid();"
: "INSERT INTO sa_unbans (ban_id, admin_id) VALUES (@banId, @adminId); SELECT last_insert_rowid();"; : "INSERT INTO sa_unbans (ban_id, admin_id) VALUES (@banId, @adminId); SELECT last_insert_rowid();";
// SQLite has no ON UPDATE CURRENT_TIMESTAMP, so updated_at must be set explicitly or the cache refresh never sees the change
public string GetUpdateBanStatusQuery() => public string GetUpdateBanStatusQuery() =>
"UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId"; "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId, updated_at = CURRENT_TIMESTAMP WHERE id = @banId";
public string GetExpireBansQuery(bool multiServer) => public string GetExpireBansQuery(bool multiServer) =>
multiServer multiServer
? "UPDATE sa_bans SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime" ? "UPDATE sa_bans SET status = 'EXPIRED', updated_at = CURRENT_TIMESTAMP WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime"
: "UPDATE sa_bans SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime AND server_id = @serverid"; : "UPDATE sa_bans SET status = 'EXPIRED', updated_at = CURRENT_TIMESTAMP WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime AND server_id = @serverid";
public string GetExpireIpBansQuery(bool multiServer) => public string GetExpireIpBansQuery(bool multiServer) =>
multiServer multiServer
@ -170,6 +171,15 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetExpireOldPlayerIpsQuery() => public string GetExpireOldPlayerIpsQuery() =>
"DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime"; "DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime";
public string GetRenamesQuery() =>
"SELECT player_steamid, name FROM sa_renames";
public string GetUpsertRenameQuery() =>
"INSERT INTO sa_renames (player_steamid, name) VALUES (@steamId, @name) ON CONFLICT(player_steamid) DO UPDATE SET name = excluded.name";
public string GetDeleteRenameQuery() =>
"DELETE FROM sa_renames WHERE player_steamid = @steamId";
public string GetAdminsQuery() => public string GetAdminsQuery() =>
""" """
SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends
@ -377,4 +387,27 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
multiServer multiServer
? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime" ? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime"
: "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime AND server_id = @serverid"; : "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime AND server_id = @serverid";
public string GetPenaltyHistoryQuery(bool multiServer) =>
$"""
SELECT b.id, 'BAN' AS type, b.player_name, b.admin_name, b.reason, b.duration, b.created, b.ends, b.status,
ub.reason AS lift_reason, ub.date AS lift_date, ua.player_name AS lift_admin
FROM sa_bans b
LEFT JOIN sa_unbans ub ON ub.id = b.unban_id
LEFT JOIN sa_admins ua ON ua.id = ub.admin_id
WHERE b.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND b.server_id = @serverid")}
UNION ALL
SELECT m.id, m.type, m.player_name, m.admin_name, m.reason, m.duration, m.created, m.ends, m.status,
um.reason AS lift_reason, um.date AS lift_date, ua.player_name AS lift_admin
FROM sa_mutes m
LEFT JOIN sa_unmutes um ON um.id = m.unmute_id
LEFT JOIN sa_admins ua ON ua.id = um.admin_id
WHERE m.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND m.server_id = @serverid")}
UNION ALL
SELECT w.id, 'WARN' AS type, w.player_name, w.admin_name, w.reason, w.duration, w.created, w.ends, w.status,
NULL AS lift_reason, NULL AS lift_date, NULL AS lift_admin
FROM sa_warns w
WHERE w.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND w.server_id = @serverid")}
ORDER BY created DESC
""";
} }

View file

@ -543,18 +543,31 @@ public partial class CS2_SimpleAdmin
[GameEventHandler] [GameEventHandler]
public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _) public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _)
{ {
var player = @event.Userid; EnforceRename(@event.Userid);
if (player is null || !player.IsValid || player.IsBot)
return HookResult.Continue; return HookResult.Continue;
}
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue; [GameEventHandler]
public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _)
if (player.PlayerName.Equals(name)) {
EnforceRename(@event.Userid);
return HookResult.Continue; return HookResult.Continue;
}
/// <summary>
/// Re-applies a permanent rename. Checked after a short delay because the engine may not
/// have written the client's new name yet when the event fires, and Rename itself
/// takes 0.4s to settle (it briefly writes the name with a trailing space).
/// </summary>
private void EnforceRename(CCSPlayerController? player)
{
if (player is null || !player.IsValid || player.IsBot) return;
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return;
AddTimer(0.5f, () =>
{
if (player.IsValid && !player.PlayerName.Equals(name))
player.Rename(name); player.Rename(name);
});
return HookResult.Continue;
} }
} }

View file

@ -49,9 +49,12 @@ internal static class Helper
} }
} }
// zio: an exact (case-insensitive) name match, or else every player whose name contains it.
public static List<CCSPlayerController> GetPlayerFromName(string name) public static List<CCSPlayerController> GetPlayerFromName(string name)
{ {
return GetValidPlayers().FindAll(x => x.PlayerName.Equals(name, StringComparison.OrdinalIgnoreCase)); var players = GetValidPlayers();
var exact = players.FindAll(x => x.PlayerName.Equals(name, StringComparison.OrdinalIgnoreCase));
return exact.Count > 0 ? exact : players.FindAll(x => x.PlayerName.Contains(name, StringComparison.OrdinalIgnoreCase));
} }
public static CCSPlayerController? GetPlayerFromSteamid64(ulong steamid) public static CCSPlayerController? GetPlayerFromSteamid64(ulong steamid)
@ -144,21 +147,35 @@ internal static class Helper
return string.Join(" ", parts); return string.Join(" ", parts);
} }
// zio: rank groups, highest first, and their chat abbreviations. Guardian (VIP) is last, so a // zio: whether an admin_overrides.json override lets the player run the command, checked the way
// staff rank wins. // CSS checks a typed command: "all" needs every flag, anything else ("any") needs one of them.
internal static bool CanRunOverriddenCommand(CCSPlayerController player, string commandName)
{
var data = AdminManager.GetCommandOverrideData(commandName);
if (data == null) return false;
var flags = data.Flags.ToArray();
BaseRequiresPermissions check = data.CheckType == "all"
? new RequiresPermissions(flags)
: new RequiresPermissionsOr(flags);
check.Command = commandName;
return check.CanExecuteCommand(player);
}
// zio: rank groups, highest first, and their chat tags (the same as CS2-Tags' chat prefixes).
// Guardian (VIP) is last, so a staff rank wins.
private static readonly (string Group, string Tag)[] StaffRanks = private static readonly (string Group, string Tag)[] StaffRanks =
[ [
("#rank/owner", "O"), ("#rank/owner", "O"),
("#rank/senioradmin", "SA"), ("#rank/senioradmin", "S.Admin"),
("#rank/admin", "A"), ("#rank/admin", "Admin"),
("#rank/trialadmin", "TA"), ("#rank/trialadmin", "T.Admin"),
("#rank/mod", "M"), ("#rank/mod", "Mod"),
("#rank/trialmod", "TM"), ("#rank/trialmod", "T.Mod"),
("#rank/helper", "H"), ("#rank/helper", "Helper"),
("#rank/guardian", "G"), ("#rank/guardian", "Guardian"),
]; ];
// zio: the player's highest rank abbreviation, or null if they're in no rank group. // zio: the player's highest rank tag, or null if they're in no rank group.
internal static string? GetStaffRankTag(CCSPlayerController player) internal static string? GetStaffRankTag(CCSPlayerController player)
{ {
var groups = AdminManager.GetPlayerAdminData(new SteamID(player.SteamID))?.Groups; var groups = AdminManager.GetPlayerAdminData(new SteamID(player.SteamID))?.Groups;
@ -989,74 +1006,6 @@ internal static class Helper
} }
public static class PluginInfo
{
internal static async Task CheckVersion(string localVersion, ILogger logger)
{
ShowAd(localVersion);
const string versionUrl = "https://raw.githubusercontent.com/daffyyyy/CS2-SimpleAdmin/main/CS2-SimpleAdmin/VERSION";
var client = CS2_SimpleAdmin.HttpClient;
client.Timeout = TimeSpan.FromSeconds(3);
try
{
var response = await client.GetAsync(versionUrl);
if (response.IsSuccessStatusCode)
{
var remoteVersion = await response.Content.ReadAsStringAsync();
remoteVersion = remoteVersion.Trim();
var comparisonResult = string.CompareOrdinal(localVersion, remoteVersion);
switch (comparisonResult)
{
case < 0:
logger.LogWarning("Plugin is outdated! Check https://github.com/daffyyyy/CS2-SimpleAdmin");
break;
case > 0:
logger.LogInformation("Probably dev version detected");
break;
default:
logger.LogInformation("Plugin is up to date");
break;
}
}
else
{
logger.LogWarning($"Failed to check version. Status Code: {response.StatusCode}");
}
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Failed to connect to the version server.");
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while checking version.");
}
}
private static void ShowAd(string moduleVersion)
{
Console.WriteLine(" ");
Console.WriteLine(" _______ ___ __ __ _______ ___ _______ _______ ______ __ __ ___ __ _ ");
Console.WriteLine("| || | | |_| || || | | || _ || | | |_| || | | | | |");
Console.WriteLine("| _____|| | | || _ || | | ___|| |_| || _ || || | | |_| |");
Console.WriteLine("| |_____ | | | || |_| || | | |___ | || | | || || | | |");
Console.WriteLine("|_____ || | | || ___|| |___ | ___|| || |_| || || | | _ |");
Console.WriteLine(" _____| || | | ||_|| || | | || |___ | _ || || ||_|| || | | | | |");
Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|");
Console.WriteLine(" >> Version: " + moduleVersion);
Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin");
var _m1 = new List<int> { 70,117,99,107,32,121,111,117,32,114,97,118,105,100,44,32,119,104,121,32,121,111,117,32,108,121,105,110,103,32,97,98,111,117,116,32,110,111,32,99,111,110,116,97,99,116,32,119,105,116,104,32,109,101,63,32,74,117,115,116,32,116,101,108,108,32,116,114,117,116,104,44,32,110,105,99,101,32,116,114,121,32,116,111,32,112,114,111,106,101,99,116,32,116,97,107,101,111,118,101,114 };
var _m2 = new List<int> { 32,32,58,68,32,40,97,108,108,32,119,104,111,32,116,114,105,101,100,32,116,111,32,99,111,110,116,97,99,116,32,119,105,116,104,32,109,101,44,32,106,117,115,116,32,119,114,105,116,101,32,109,101,115,115,97,103,101,32,116,111,32,109,101,32,120,68,41 };
Console.WriteLine(" >> " + new string(_m1.Select(c => (char)c).ToArray()));
Console.WriteLine(" >> " + new string(_m2.Select(c => (char)c).ToArray()));
Console.WriteLine(" ");
}
}
public class SchemaString<TSchemaClass>(TSchemaClass instance, string member) public class SchemaString<TSchemaClass>(TSchemaClass instance, string member)
: NativeObject(Schema.GetSchemaValue<nint>(instance.Handle, typeof(TSchemaClass).Name, member)) : NativeObject(Schema.GetSchemaValue<nint>(instance.Handle, typeof(TSchemaClass).Name, member))
where TSchemaClass : NativeObject where TSchemaClass : NativeObject

View file

@ -1,5 +1,6 @@
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.ValveConstants.Protobuf; using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CounterStrikeSharp.API.Modules.Entities;
using CS2_SimpleAdminApi; using CS2_SimpleAdminApi;
using Dapper; using Dapper;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -339,8 +340,34 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string
var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery(); var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery();
await connection.ExecuteAsync(sqlUpdateBan, new { unbanId, banId }); await connection.ExecuteAsync(sqlUpdateBan, new { unbanId, banId });
} }
// Apply immediately; the periodic cache refresh would otherwise keep rejecting the player for up to a minute
CS2_SimpleAdmin.Instance.CacheManager?.SetBanStatus(bansList.Select(b => (int)b.id), Models.BanStatus.UNBANNED);
// css_ban also issues a native banid when UnlockedCommands is on; clear it so the engine stops
// rejecting the player. Done here, after the rows are updated, and for whichever pattern matched them.
if (CS2_SimpleAdmin.UnlockedCommands)
{
var steamIds = bansList
.Select(b => (object?)b.player_steamid)
.Where(id => id != null)
.Select(id => Convert.ToUInt64(id))
.Where(id => id > 0)
.Distinct()
.ToList();
if (steamIds.Count > 0)
await Server.NextWorldUpdateAsync(() =>
{
foreach (var steamId in steamIds)
Server.ExecuteCommand($"removeid {new SteamID(steamId).SteamId3}");
});
}
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to unban player: {exception}", ex.Message);
} }
catch { }
} }
// public async Task CheckOnlinePlayers(List<(string? IpAddress, ulong SteamID, int? UserId, int Slot)> players) // public async Task CheckOnlinePlayers(List<(string? IpAddress, ulong SteamID, int? UserId, int Slot)> players)

View file

@ -10,8 +10,10 @@ namespace CS2_SimpleAdmin.Managers;
internal class CacheManager: IDisposable internal class CacheManager: IDisposable
{ {
private readonly ConcurrentDictionary<int, BanRecord> _banCache = []; private readonly ConcurrentDictionary<int, BanRecord> _banCache = [];
private readonly ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = []; // Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index
private readonly ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = []; private ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = [];
private ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = [];
private readonly object _indexLock = new();
private readonly ConcurrentDictionary<ulong, HashSet<IpRecord>> _playerIpsCache = []; private readonly ConcurrentDictionary<ulong, HashSet<IpRecord>> _playerIpsCache = [];
private HashSet<uint> _cachedIgnoredIps = []; private HashSet<uint> _cachedIgnoredIps = [];
@ -178,7 +180,7 @@ internal class CacheManager: IDisposable
player_steamid AS PlayerSteamId, player_steamid AS PlayerSteamId,
player_ip AS PlayerIp, player_ip AS PlayerIp,
status AS Status status AS Status
FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC FROM `sa_bans` WHERE updated_at >= @lastUpdate OR created >= @lastUpdate ORDER BY updated_at DESC
""", """,
new { lastUpdate = lastCheckTime } new { lastUpdate = lastCheckTime }
)).ToList(); )).ToList();
@ -221,7 +223,7 @@ internal class CacheManager: IDisposable
player_steamid AS PlayerSteamId, player_steamid AS PlayerSteamId,
player_ip AS PlayerIp, player_ip AS PlayerIp,
status AS Status status AS Status
FROM `sa_bans` WHERE server_id = @serverId AND (updated_at > @lastUpdate OR created > @lastUpdate) ORDER BY updated_at DESC FROM `sa_bans` WHERE server_id = @serverId AND (updated_at >= @lastUpdate OR created >= @lastUpdate) ORDER BY updated_at DESC
""", """,
new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime } new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime }
)).ToList(); )).ToList();
@ -294,7 +296,7 @@ internal class CacheManager: IDisposable
.GroupBy(x => x.Address) .GroupBy(x => x.Address)
.Select(g => .Select(g =>
{ {
var latest = g.MaxBy(x => x.Used_at); var latest = g.MaxBy(x => x.Used_at)!; // group is never empty
return new IpRecord( return new IpRecord(
g.Key, g.Key,
latest.Used_at, latest.Used_at,
@ -353,8 +355,36 @@ internal class CacheManager: IDisposable
/// </summary> /// </summary>
private void RebuildIndexes() private void RebuildIndexes()
{ {
_steamIdIndex.Clear(); // SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here
_ipIndex.Clear(); lock (_indexLock)
{
RebuildIndexesCore();
}
}
/// <summary>
/// Updates the status of the given bans in the cache and rebuilds the indexes,
/// so unbans take effect immediately instead of waiting for the next refresh.
/// </summary>
/// <param name="banIds">Ban ids to update.</param>
/// <param name="status">New status.</param>
public void SetBanStatus(IEnumerable<int> banIds, BanStatus status)
{
var changed = false;
foreach (var id in banIds)
{
if (!_banCache.TryGetValue(id, out var ban) || ban.StatusEnum == status) continue;
_banCache[id] = ban with { Status = status.ToString() };
changed = true;
}
if (changed) RebuildIndexes();
}
private void RebuildIndexesCore()
{
var steamIdIndex = new ConcurrentDictionary<ulong, List<BanRecord>>();
var ipIndex = new ConcurrentDictionary<uint, List<BanRecord>>();
// Optimization: Cache config value to avoid repeated property access // Optimization: Cache config value to avoid repeated property access
var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType; var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType;
@ -369,10 +399,10 @@ internal class CacheManager: IDisposable
if (ban.PlayerSteamId.HasValue) if (ban.PlayerSteamId.HasValue)
{ {
var steamId = ban.PlayerSteamId.Value; var steamId = ban.PlayerSteamId.Value;
if (!_steamIdIndex.TryGetValue(steamId, out var steamList)) if (!steamIdIndex.TryGetValue(steamId, out var steamList))
{ {
steamList = new List<BanRecord>(); steamList = new List<BanRecord>();
_steamIdIndex[steamId] = steamList; steamIdIndex[steamId] = steamList;
} }
steamList.Add(ban); steamList.Add(ban);
} }
@ -381,14 +411,18 @@ internal class CacheManager: IDisposable
if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) && if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) &&
IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt)) IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt))
{ {
if (!_ipIndex.TryGetValue(ipUInt, out var ipList)) if (!ipIndex.TryGetValue(ipUInt, out var ipList))
{ {
ipList = new List<BanRecord>(); ipList = new List<BanRecord>();
_ipIndex[ipUInt] = ipList; ipIndex[ipUInt] = ipList;
} }
ipList.Add(ban); ipList.Add(ban);
} }
} }
// Reference swap is atomic; readers finish on the old index or start on the complete new one
Volatile.Write(ref _steamIdIndex, steamIdIndex);
Volatile.Write(ref _ipIndex, ipIndex);
} }
/// <summary> /// <summary>

View file

@ -212,7 +212,15 @@ internal class MuteManager(IDatabaseProvider? databaseProvider)
foreach (var muteRecord in muteRecords) foreach (var muteRecord in muteRecords)
{ {
DateTime endDateTime = muteRecord.ends; DateTime endDateTime = muteRecord.ends;
PlayerPenaltyManager.RemovePenaltiesByDateTime(slot, endDateTime); // zio: match by type and approximate end time; see RemovePenaltiesByDateTime.
string muteType = muteRecord.type;
var penaltyType = muteType switch
{
"MUTE" => PenaltyType.Mute,
"SILENCE" => PenaltyType.Silence,
_ => PenaltyType.Gag
};
PlayerPenaltyManager.RemovePenaltiesByDateTime(slot, penaltyType, endDateTime);
} }
} }

View file

@ -17,8 +17,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// <summary> /// <summary>
/// Retrieves all players' flags and associated data asynchronously. /// Retrieves all players' flags and associated data asynchronously.
/// </summary> /// </summary>
/// <returns>A list of tuples containing player SteamID, name, flags, immunity, and expiration time.</returns> /// <returns>A list of tuples containing player SteamID, name, flags, immunity, and expiration time, or null if the database query failed.</returns>
private async Task<List<(ulong, string ,List<string>, int, DateTime?)>> GetAllPlayersFlags() private async Task<List<(ulong, string ,List<string>, int, DateTime?)>?> GetAllPlayersFlags()
{ {
if (databaseProvider == null) if (databaseProvider == null)
return new List<(ulong, string, List<string>, int, DateTime?)>(); return new List<(ulong, string, List<string>, int, DateTime?)>();
@ -68,7 +68,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
catch (Exception ex) catch (Exception ex)
{ {
CS2_SimpleAdmin._logger?.LogError("Unable to load admins from database! {exception}", ex.Message); CS2_SimpleAdmin._logger?.LogError("Unable to load admins from database! {exception}", ex.Message);
return []; // zio: null rather than empty, so ReloadAdmins keeps the loaded admins instead of removing them all.
return null;
} }
} }
@ -150,85 +151,37 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// <summary> /// <summary>
/// Creates a JSON file containing admins data asynchronously. /// Creates a JSON file containing admins data asynchronously.
/// </summary> /// </summary>
/// <returns>zio: every admin row loaded, for ReloadAdmins to rebuild AdminCache from, or null if the database query failed (the file is then left alone).</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")] [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
public async Task CreateAdminsJsonFile() public async Task<List<(SteamID SteamId, DateTime? Ends, List<string> Flags)>?> CreateAdminsJsonFile()
{ {
List<(ulong identity, string name, List<string> flags, int immunity, DateTime? ends)> allPlayers = await GetAllPlayersFlags(); var allPlayers = await GetAllPlayersFlags();
if (allPlayers == null) return null;
var validPlayers = allPlayers var validPlayers = allPlayers
.Where(player => SteamID.TryParse(player.identity.ToString(), out _)) .Select(player => (player, steamId: SteamID.TryParse(player.Item1.ToString(), out var steamId) ? steamId : null))
.Where(p => p.steamId != null)
.ToList(); .ToList();
// zio: keyed by SteamID, not name: CSS ignores the key, and two admins with the same name were
// merged into one entry under the first one's SteamID. Upstream also cleared and refilled
// AdminCache here once per admin, so it only ever kept the last admin and a reload never
// removed anyone else; ReloadAdmins now does that once, from the list returned here.
var jsonData = validPlayers var jsonData = validPlayers
.GroupBy(player => player.name) .GroupBy(p => p.steamId!.SteamId64)
.ToDictionary( .ToDictionary(
group => group.Key, group => group.Key.ToString(),
object (group) => object (group) =>
{ {
var consolidatedData = group.Aggregate( var rows = group.Select(p => p.player).ToList();
new var flags = rows.SelectMany(r => r.Item3).Distinct().ToList();
return new
{ {
identity = string.Empty, identity = group.Key.ToString(),
immunity = 0, immunity = rows.Max(r => r.Item4),
flags = new List<string>(), flags = flags.Where(flag => flag.StartsWith($"@")).ToList(),
groups = new List<string>() groups = flags.Where(flag => flag.StartsWith($"#")).ToList()
},
(acc, player) =>
{
if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString()))
{
acc = acc with { identity = player.identity.ToString() };
}
acc = acc with { immunity = Math.Max(acc.immunity, player.immunity) };
acc = acc with
{
flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(),
groups = acc.groups.Concat(player.flags.Where(flag => flag.StartsWith($"#"))).Distinct().ToList()
}; };
return acc;
});
Server.NextWorldUpdate(() =>
{
var keysToRemove = new List<SteamID>();
foreach (var steamId in AdminCache.Keys.ToList())
{
var data = AdminManager.GetPlayerAdminData(steamId);
if (data != null)
{
var flagsArray = AdminCache[steamId].Flags.ToArray();
AdminManager.RemovePlayerPermissions(steamId, flagsArray);
AdminManager.RemovePlayerFromGroup(steamId, true, flagsArray);
}
keysToRemove.Add(steamId);
}
foreach (var steamId in keysToRemove)
{
if (!AdminCache.TryRemove(steamId, out _)) continue;
var data = AdminManager.GetPlayerAdminData(steamId);
if (data == null) continue;
if (data.Flags.Count != 0 && data.Groups.Count != 0) continue;
AdminManager.ClearPlayerPermissions(steamId);
AdminManager.RemovePlayerAdminData(steamId);
}
foreach (var player in group)
{
if (SteamID.TryParse(player.identity.ToString(), out var steamId) && steamId != null)
{
AdminCache.TryAdd(steamId, (player.ends, player.flags));
}
}
});
return consolidatedData;
}); });
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
@ -240,6 +193,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var json = JsonSerializer.Serialize(jsonData, options); var json = JsonSerializer.Serialize(jsonData, options);
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json"); var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
return validPlayers.Select(p => (p.steamId!, p.player.Item5, p.player.Item3)).ToList();
} }
/// <summary> /// <summary>
@ -247,7 +202,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// </summary> /// </summary>
/// <param name="playerSteamId">The SteamID of the admin to delete.</param> /// <param name="playerSteamId">The SteamID of the admin to delete.</param>
/// <param name="globalDelete">Whether to delete the admin globally or only for the current server.</param> /// <param name="globalDelete">Whether to delete the admin globally or only for the current server.</param>
public async Task DeleteAdminBySteamId(string playerSteamId, bool globalDelete = false) /// <param name="adminSteamId">zio: SteamID64 of who removed them, 0 for Console, for the rank log.</param>
/// <param name="adminName">zio: name of who removed them, for the rank log.</param>
public async Task DeleteAdminBySteamId(string playerSteamId, bool globalDelete = false, ulong adminSteamId = 0, string adminName = "Console")
{ {
if (databaseProvider == null) return; if (databaseProvider == null) return;
if (string.IsNullOrEmpty(playerSteamId)) return; if (string.IsNullOrEmpty(playerSteamId)) return;
@ -255,8 +212,14 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); await using var connection = await databaseProvider.CreateConnectionAsync();
// zio: note the rank and name before they're gone, for the rank log.
var before = await GetRank(connection, playerSteamId);
var playerName = await connection.ExecuteScalarAsync<string?>(
"SELECT player_name FROM sa_admins WHERE player_steamid = @playerSteamId ORDER BY server_id IS NULL DESC, id LIMIT 1",
new { playerSteamId }) ?? "";
var sql = databaseProvider.GetDeleteAdminQuery(globalDelete); var sql = databaseProvider.GetDeleteAdminQuery(globalDelete);
await connection.ExecuteAsync(sql, new { PlayerSteamID = playerSteamId, CS2_SimpleAdmin.ServerId }); await connection.ExecuteAsync(sql, new { PlayerSteamID = playerSteamId, CS2_SimpleAdmin.ServerId });
await LogRankChange(connection, playerSteamId, playerName, before, await GetRank(connection, playerSteamId), adminSteamId, adminName);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -273,7 +236,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// <param name="immunity">Immunity level.</param> /// <param name="immunity">Immunity level.</param>
/// <param name="time">Duration in minutes for admin expiration; 0 means permanent.</param> /// <param name="time">Duration in minutes for admin expiration; 0 means permanent.</param>
/// <param name="globalAdmin">Whether the admin is global or server-specific.</param> /// <param name="globalAdmin">Whether the admin is global or server-specific.</param>
public async Task AddAdminBySteamId(string playerSteamId, string playerName, List<string> flagsList, int immunity = 0, int time = 0, bool globalAdmin = false) /// <param name="adminSteamId">zio: SteamID64 of who added them, 0 for Console, for the rank log.</param>
/// <param name="adminName">zio: name of who added them, for the rank log.</param>
public async Task AddAdminBySteamId(string playerSteamId, string playerName, List<string> flagsList, int immunity = 0, int time = 0, bool globalAdmin = false, ulong adminSteamId = 0, string adminName = "Console")
{ {
if (databaseProvider == null) return; if (databaseProvider == null) return;
@ -285,6 +250,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); await using var connection = await databaseProvider.CreateConnectionAsync();
var before = await GetRank(connection, playerSteamId); // zio: for the rank log
var insertAdminSql = databaseProvider.GetAddAdminQuery(); var insertAdminSql = databaseProvider.GetAddAdminQuery();
var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new
@ -307,6 +273,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
}); });
} }
await LogRankChange(connection, playerSteamId, playerName, before, await GetRank(connection, playerSteamId), adminSteamId, adminName);
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
CS2_SimpleAdmin.Instance.ReloadAdmins(null); CS2_SimpleAdmin.Instance.ReloadAdmins(null);
@ -318,6 +286,69 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
} }
} }
// zio: rank history in sa_rank_log, a table owned by our web panel (simpleadmin-web's
// sql/rank-log.sql), which logs its own changes there too. A player's rank is their
// highest-immunity group; a change is only logged when that changes.
private sealed class RankRow
{
public string Name { get; set; } = "";
public int Immunity { get; set; }
}
private static bool _rankLogMissingWarned;
// zio: the player's current rank, or null. Only unexpired admin rows and groups that apply to
// this server count, the same set SimpleAdmin loads.
private static async Task<RankRow?> GetRank(System.Data.Common.DbConnection connection, string playerSteamId)
{
return await connection.QueryFirstOrDefaultAsync<RankRow>("""
SELECT g.name AS Name, g.immunity AS Immunity
FROM sa_admins a
JOIN sa_admins_flags f ON f.admin_id = a.id
JOIN sa_groups g ON g.name = f.flag
JOIN sa_groups_servers gs ON gs.group_id = g.id AND (gs.server_id IS NULL OR gs.server_id = @serverid)
WHERE a.player_steamid = @playerSteamId AND (a.ends IS NULL OR a.ends > @now)
AND (a.server_id IS NULL OR a.server_id = @serverid)
ORDER BY g.immunity DESC, g.id LIMIT 1
""", new { playerSteamId, now = Time.ActualDateTime(), serverid = CS2_SimpleAdmin.ServerId });
}
// zio: records a rank change in sa_rank_log. Never fails the admin change itself: if the table
// is missing (the panel hasn't created it), it warns once and carries on.
private static async Task LogRankChange(System.Data.Common.DbConnection connection, string playerSteamId, string playerName,
RankRow? before, RankRow? after, ulong adminSteamId, string adminName)
{
if (before?.Name == after?.Name) return;
var kind = before == null ? "added"
: after == null ? "removed"
: after.Immunity > before.Immunity ? "promoted"
: after.Immunity < before.Immunity ? "demoted"
: "changed";
try
{
await connection.ExecuteAsync("""
INSERT INTO sa_rank_log (player_steamid, player_name, old_rank, new_rank, kind, admin_steamid, admin_name, source, created)
VALUES (@playerSteamId, @playerName, @oldRank, @newRank, @kind, @adminSteamId, @adminName, 'game', @created)
""", new
{
playerSteamId = ulong.Parse(playerSteamId),
playerName,
oldRank = before?.Name,
newRank = after?.Name,
kind,
adminSteamId,
adminName,
created = Time.ActualDateTime()
});
}
catch (Exception ex)
{
if (_rankLogMissingWarned) return;
_rankLogMissingWarned = true;
CS2_SimpleAdmin._logger?.LogWarning($"Couldn't write sa_rank_log (the web panel creates it): {ex.Message}");
}
}
/// <summary> /// <summary>
/// Adds a new group with flags and immunity asynchronously. /// Adds a new group with flags and immunity asynchronously.
/// </summary> /// </summary>

View file

@ -1,3 +1,4 @@
using System.Globalization;
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Admin;
@ -92,6 +93,11 @@ internal class PlayerManager
// zio: read on the main thread; the ban check below runs on a worker thread. // zio: read on the main thread; the ban check below runs on a worker thread.
var bypassIpBans = AdminManager.PlayerHasPermissions(new SteamID(steamId), IpBanBypassPermission); var bypassIpBans = AdminManager.PlayerHasPermissions(new SteamID(steamId), IpBanBypassPermission);
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(steamId, out var renamedTo))
{
player.Rename(renamedTo);
}
if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return; if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return;
Task.Run(async () => Task.Run(async () =>
@ -297,10 +303,92 @@ internal class PlayerManager
_loadPlayerSemaphore.Release(); _loadPlayerSemaphore.Release();
} }
}); });
}
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name)) /// <summary>
/// Returns every ban, gag/mute/silence and warn ever recorded for a SteamID, newest first.
/// Rows carry: type, status, reason, duration, created, ends, admin_name, lift_reason, lift_date, lift_admin.
/// </summary>
/// <param name="steamId">SteamID64 of the player, online or not.</param>
/// <param name="type">Optional filter: bans, gags, mutes, silences or warns.</param>
public async Task<List<dynamic>> GetPenaltyHistory(ulong steamId, string? type = null)
{ {
player.Rename(name); if (CS2_SimpleAdmin.DatabaseProvider == null) return [];
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var sql = CS2_SimpleAdmin.DatabaseProvider.GetPenaltyHistoryQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode);
var rows = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, serverid = CS2_SimpleAdmin.ServerId })).ToList();
// Filter word is the plural (bans, gags, mutes, silences, warns); rows carry BAN/GAG/MUTE/SILENCE/WARN
if (type != null)
rows.RemoveAll(r => !type.Equals((string)r.type + "s", StringComparison.OrdinalIgnoreCase));
return rows;
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load penalty history: {exception}", ex.Message);
return [];
}
}
/// <summary>
/// Dapper gives DateTime on MySQL but can give a string on SQLite; accept both.
/// </summary>
internal static DateTime? ToDateTime(object? value) =>
value switch
{
DateTime d => d,
string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) => parsed,
_ => null
};
/// <summary>
/// Loads permanent renames from the database into memory.
/// </summary>
public async Task LoadRenamedPlayers()
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var rows = await connection.QueryAsync<(long steamId, string name)>(CS2_SimpleAdmin.DatabaseProvider.GetRenamesQuery());
await Server.NextWorldUpdateAsync(() =>
{
foreach (var (steamId, name) in rows)
CS2_SimpleAdmin.RenamedPlayers[(ulong)steamId] = name;
});
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load renames: {exception}", ex.Message);
}
}
/// <summary>
/// Persists or removes a permanent rename (css_prename) for a player.
/// </summary>
/// <param name="steamId">SteamID64 of the player.</param>
/// <param name="name">Forced name, or null/empty to remove the rename.</param>
public async Task SaveRenamedPlayer(ulong steamId, string? name)
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
if (string.IsNullOrEmpty(name))
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetDeleteRenameQuery(), new { steamId });
else
await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetUpsertRenameQuery(), new { steamId, name });
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError("Unable to save rename: {exception}", ex.Message);
} }
} }

View file

@ -95,9 +95,11 @@ public static class PlayerPenaltyManager
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.TimeMode == 0) if (CS2_SimpleAdmin.Instance.Config.OtherSettings.TimeMode == 0)
{ {
if (penaltiesList.Count == 0) return false; // zio: a penalty marked passed is over, even before RemoveExpiredPenalties drops it.
var active = penaltiesList.FirstOrDefault(p => !p.Passed || p.Duration == 0);
if (active == default) return false;
endDateTime = penaltiesList.First().EndDateTime; endDateTime = active.EndDateTime;
return true; return true;
} }
@ -255,6 +257,26 @@ public static class PlayerPenaltyManager
} }
} }
/// <summary>
/// zio: marks a penalty of one type as passed when its end is within a few seconds of the
/// database's. A penalty added in game keeps the end time from the command, which has
/// sub-second precision and is taken a moment before the one the database stores (to the
/// second), so an exact match never finds it and the penalty outlives its database row.
/// </summary>
public static void RemovePenaltiesByDateTime(int slot, PenaltyType penaltyType, DateTime dateTime)
{
if (!Penalties.TryGetValue(slot, out var penaltyDict) ||
!penaltyDict.TryGetValue(penaltyType, out var penaltiesList)) return;
for (var i = 0; i < penaltiesList.Count; i++)
{
if ((penaltiesList[i].EndDateTime - dateTime).Duration() > TimeSpan.FromSeconds(5)) continue;
var penalty = penaltiesList[i];
penalty.Passed = true;
penaltiesList[i] = penalty;
}
}
/// <summary> /// <summary>
/// Removes or expires penalties automatically across all players based on their duration or "passed" flag. /// Removes or expires penalties automatically across all players based on their duration or "passed" flag.
/// </summary> /// </summary>

View file

@ -43,6 +43,7 @@ public class ServerManager
if (_getIpTryCount > 32 && isInvalidIp) if (_getIpTryCount > 32 && isInvalidIp)
{ {
CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!"); CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!");
_ = CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers();
return; return;
} }
@ -115,6 +116,9 @@ CS2_SimpleAdmin.ServerLoaded = true;
CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message); CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message);
} }
// Renames are server-independent, so load them even when the server row could not be resolved
await CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers();
if (CS2_SimpleAdmin.Instance.Config.EnableMetrics) if (CS2_SimpleAdmin.Instance.Config.EnableMetrics)
{ {
var queryString = $"?address={address}&hostname={hostname}"; var queryString = $"?address={address}&hostname={hostname}";

View file

@ -20,20 +20,22 @@ public abstract class BasicMenu
var manager = MenuManager.Instance; var manager = MenuManager.Instance;
// Players category menus - using translation keys // Players category menus - using translation keys
manager.RegisterMenu("players", "slap", "sa_slap", CreateSlapMenu, "@css/slay"); // zio: each entry names its command, so admin_overrides.json applies to the menu too.
manager.RegisterMenu("players", "slay", "sa_slay", CreateSlayMenu, "@css/slay"); manager.RegisterMenu("players", "slap", "sa_slap", CreateSlapMenu, "@css/slay", "css_slap");
manager.RegisterMenu("players", "kick", "sa_kick", CreateKickMenu, "@css/kick"); manager.RegisterMenu("players", "slay", "sa_slay", CreateSlayMenu, "@css/slay", "css_slay");
manager.RegisterMenu("players", "warn", "sa_warn", CreateWarnMenu, "@css/kick"); manager.RegisterMenu("players", "kick", "sa_kick", CreateKickMenu, "@css/kick", "css_kick");
manager.RegisterMenu("players", "ban", "sa_ban", CreateBanMenu, "@css/ban"); manager.RegisterMenu("players", "warn", "sa_warn", CreateWarnMenu, "@css/kick", "css_warn");
manager.RegisterMenu("players", "gag", "sa_gag", CreateGagMenu, "@css/chat"); manager.RegisterMenu("players", "ban", "sa_ban", CreateBanMenu, "@css/ban", "css_ban");
manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat"); manager.RegisterMenu("players", "gag", "sa_gag", CreateGagMenu, "@css/chat", "css_gag");
manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat"); manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat", "css_mute");
manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick"); manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat", "css_silence");
manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick", "css_team");
manager.RegisterMenu("players", "history", "sa_history", CreateHistoryMenu, "@css/kick", "css_history");
// Server category menus - using translation keys // Server category menus - using translation keys
manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root"); manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root");
manager.RegisterMenu("server", "changemap", "sa_changemap", CreateChangeMapMenu, "@css/changemap"); manager.RegisterMenu("server", "changemap", "sa_changemap", CreateChangeMapMenu, "@css/changemap", "css_map");
manager.RegisterMenu("server", "restart", "sa_restart_game", CreateRestartGameMenu, "@css/generic"); manager.RegisterMenu("server", "restart", "sa_restart_game", CreateRestartGameMenu, "@css/generic", "css_rr");
manager.RegisterMenu("server", "custom", "sa_menu_custom_commands", CreateCustomCommandsMenu, "@css/generic"); manager.RegisterMenu("server", "custom", "sa_menu_custom_commands", CreateCustomCommandsMenu, "@css/generic");
// Admin category menus - using translation keys // Admin category menus - using translation keys
@ -127,6 +129,62 @@ public abstract class BasicMenu
return slayMenu.WithBackButton(); return slayMenu.WithBackButton();
} }
/// <summary>
/// Creates the penalty history menu: pick an online player, then one entry per past penalty.
/// Selecting an entry prints its full details to the admin's console.
/// </summary>
/// <param name="admin">The admin player opening the menu.</param>
/// <returns>A MenuBuilder instance for the history menu.</returns>
private static MenuBuilder CreateHistoryMenu(CCSPlayerController admin)
{
var localizer = CS2_SimpleAdmin._localizer;
var historyMenu = new MenuBuilder("sa_history", admin, localizer);
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players)
{
var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
historyMenu.AddOption(playerName, _ =>
{
var steamId = player.SteamID;
var name = player.PlayerName;
var adminSteamId = admin.SteamID;
// The list is built after the DB round trip, so it is opened from the world update rather than returned here
Task.Run(async () =>
{
var rows = await CS2_SimpleAdmin.PlayerManager.GetPenaltyHistory(steamId);
await Server.NextWorldUpdateAsync(() =>
{
// The slot may have been reused by another player while the query ran
if (!admin.IsValid || admin.SteamID != adminSteamId) return;
if (rows.Count == 0)
{
CS2_SimpleAdmin.Instance.PrintHistory(admin.PrintToConsole, name, steamId, rows);
return;
}
var list = new MenuBuilder("sa_history", admin, localizer);
foreach (var row in rows)
{
var active = (string)row.status == "ACTIVE";
var label = $"[{(active ? $"{ChatColors.LightRed}X" : $"{ChatColors.Lime}✔️")}{ChatColors.Default}] {(string)row.type} {(string)row.reason}";
list.AddOption(label.Length > 40 ? label[..40] : label,
_ => CS2_SimpleAdmin.Instance.PrintHistory(admin.PrintToConsole, name, steamId, [row]));
}
// Built standalone, so the back button must be wired to the player list explicitly
list.WithBackAction(historyMenu.OpenMenu).OpenMenu(admin);
});
});
});
}
return historyMenu.WithBackButton();
}
/// <summary> /// <summary>
/// Creates menu for kicking players with reason selection. /// Creates menu for kicking players with reason selection.
/// </summary> /// </summary>

View file

@ -219,8 +219,9 @@ public class MenuManager
// Check if command has overridden permissions // Check if command has overridden permissions
if (AdminManager.CommandIsOverriden(commandName)) if (AdminManager.CommandIsOverriden(commandName))
{ {
var overriddenPermission = AdminManager.GetPermissionOverrides(commandName); // zio: honour the override's check_type ("any" passes with one flag), as CSS does
hasPermission = AdminManager.PlayerHasPermissions(steamId, overriddenPermission); // for the typed command, instead of requiring every flag.
hasPermission = Helper.CanRunOverriddenCommand(player, commandName);
} }
else if (!string.IsNullOrEmpty(permission)) else if (!string.IsNullOrEmpty(permission))
{ {

View file

@ -77,7 +77,7 @@ public partial class CS2_SimpleAdmin
internal MuteManager MuteManager = new(DatabaseProvider); internal MuteManager MuteManager = new(DatabaseProvider);
internal WarnManager WarnManager = new(DatabaseProvider); internal WarnManager WarnManager = new(DatabaseProvider);
internal CacheManager? CacheManager = new(); internal CacheManager? CacheManager = new();
private static readonly PlayerManager PlayerManager = new(); internal static readonly PlayerManager PlayerManager = new();
// Timers // Timers
internal Timer? PlayersTimer = null; internal Timer? PlayersTimer = null;

View file

@ -1,5 +1,6 @@
{GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT} {GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT}
- css_who <#userid or name> - Display informations about player - css_who <#userid or name> - Display informations about player
- css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns] - Print player's penalty history to console
- css_players - Display player list - css_players - Display player list
- css_ban <#userid or name> [time in minutes/0 perm] [reason] - Ban player - css_ban <#userid or name> [time in minutes/0 perm] [reason] - Ban player
- css_addban <steamid> [time in minutes/0 perm] [reason] - Ban player via steamid64 - css_addban <steamid> [time in minutes/0 perm] [reason] - Ban player via steamid64

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات", "sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات",
"sa_admin_warns_unwarn": "{lime}تم إلغاء التحذير بنجاح{default} لـ {gold}{0} {default}بسبب {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}تم إلغاء التحذير بنجاح{default} لـ {gold}{0} {default}بسبب {gold}{1}{default}!",
"sa_history": "السجل",
"sa_history_header": "===== سجل العقوبات للاعب {0} ({1}) - {2} سجل =====",
"sa_history_none": "لم يتم العثور على عقوبات للاعب {0}.",
"sa_admin_vote_menu_title": "{lime}تصويت لـ {gold}{0}", "sa_admin_vote_menu_title": "{lime}تصويت لـ {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}نتائج التصويت لـ {gold}{0}", "sa_admin_vote_message_results": "{lime}نتائج التصويت لـ {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} hat {lightred}{1}{default} zu {lightred}{2}{default} transferiert!", "sa_admin_team_message": "{lightred}{0}{default} hat {lightred}{1}{default} zu {lightred}{2}{default} transferiert!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}VERWARNUNGEN", "sa_admin_warns_menu_title": "{gold}{0} {lime}VERWARNUNGEN",
"sa_admin_warns_unwarn": "{lime}Erfolgreich{default} Verwarnung von {gold}{0} {default}aufgehoben wegen {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Erfolgreich{default} Verwarnung von {gold}{0} {default}aufgehoben wegen {gold}{1}{default}!",
"sa_history": "Verlauf",
"sa_history_header": "===== Strafverlauf für {0} ({1}) - {2} Eintrag/Einträge =====",
"sa_history_none": "Keine Strafen für {0} gefunden.",
"sa_admin_vote_menu_title": "{lime}ABSTIMMUNG FÜR {gold}{0}", "sa_admin_vote_menu_title": "{lime}ABSTIMMUNG FÜR {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet", "sa_admin_vote_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet",
"sa_admin_vote_message_results": "{lime}ABSTIMMUNGSERGEBNISSE FÜR {gold}{0}", "sa_admin_vote_message_results": "{lime}ABSTIMMUNGSERGEBNISSE FÜR {gold}{0}",

View file

@ -1,10 +1,10 @@
{ {
"sa_title": "SimpleAdmin", "sa_title": "SimpleAdmin",
"sa_prefix": "{lightred}[SA] {default}", "sa_prefix": "{default}[{red}zio{default}] ",
"sa_unknown": "Unknown", "sa_unknown": "Unknown",
"sa_no_permission": "You do not have permissions to use this command.", "sa_no_permission": "You do not have permissions to use this command.",
"sa_ban_max_duration_exceeded": "Ban duration cannot exceed {lightred}{0}{default} minutes.", "sa_ban_max_duration_exceeded": "Ban duration cannot exceed {red}{0}{default} minutes.",
"sa_ban_perm_restricted": "You do not have the right to permanently ban.", "sa_ban_perm_restricted": "You do not have the right to permanently ban.",
"sa_admin_add": "Add Admin", "sa_admin_add": "Add Admin",
@ -64,30 +64,30 @@
"sa_discord_penalty_warn": "Warn registered", "sa_discord_penalty_warn": "Warn registered",
"sa_discord_penalty_unknown": "Unknown registered", "sa_discord_penalty_unknown": "Unknown registered",
"sa_player_penalty_chat_active": "{lightred}Your chat is blocked to: {grey}{0}", "sa_player_penalty_chat_active": "{red}Your chat is blocked until: {grey}{0}",
"sa_player_penalty_gag_active": "{lightred}You are gagged for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", "sa_player_penalty_gag_active": "You are gagged for {red}{0}{default}.\nReason: {red}{1}",
"sa_player_penalty_gag_active_perm": "{lightred}You are gagged permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", "sa_player_penalty_gag_active_perm": "You are gagged {red}permanently{default}.\nReason: {red}{0}",
"sa_player_penalty_silence_active": "{lightred}You are silenced for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", "sa_player_penalty_silence_active": "You are silenced for {red}{0}{default}.\nReason: {red}{1}",
"sa_player_penalty_silence_active_perm": "{lightred}You are silenced permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", "sa_player_penalty_silence_active_perm": "You are silenced {red}permanently{default}.\nReason: {red}{0}",
"sa_player_penalty_no_reason": "none given", "sa_player_penalty_no_reason": "none given",
"sa_player_ban_active": "{lightred}You are banned from this server for {grey}{0}{lightred}. Reason: {grey}{1}", "sa_player_ban_active": "You are banned from this server for {red}{0}{default}.\nReason: {red}{1}",
"sa_player_ban_active_perm": "{lightred}You are banned from this server permanently. Reason: {grey}{0}", "sa_player_ban_active_perm": "You are banned from this server {red}permanently{default}.\nReason: {red}{0}",
"sa_player_ban_active_other_account": "{grey}The ban is on another account that has played from your IP address.", "sa_player_ban_active_other_account": "The ban is on another account that has played from your IP address.",
"sa_player_ban_active_kick": "{grey}You will be disconnected in a few seconds.", "sa_player_ban_active_kick": "You will be disconnected in a few seconds.",
"sa_player_penalty_info_active_mute": "➔ Mute [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_mute": "➔ Mute [{lightred}❌{default}] - Expire [{red}{0}{default}]",
"sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{red}{0}{default}]",
"sa_player_penalty_info_active_silence": "➔ Silence [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_silence": "➔ Silence [{lightred}❌{default}] - Expire [{red}{0}{default}]",
"sa_player_penalty_info_active_warn": "➔ Warn [{lightred}❌{default}] - Expire [{lightred}{0}{default}] - Reason [{lightred}{1}{default}]", "sa_player_penalty_info_active_warn": "➔ Warn [{lightred}❌{default}] - Expire [{red}{0}{default}] - Reason [{red}{1}{default}]",
"sa_player_penalty_info_no_active_mute": "➔ Mute [{lime}✔{default}]", "sa_player_penalty_info_no_active_mute": "➔ Mute [{lime}✔{default}]",
"sa_player_penalty_info_no_active_gag": "➔ Gag [{lime}✔{default}]", "sa_player_penalty_info_no_active_gag": "➔ Gag [{lime}✔{default}]",
"sa_player_penalty_info_no_active_silence": "➔ Silence [{lime}✔{default}]", "sa_player_penalty_info_no_active_silence": "➔ Silence [{lime}✔{default}]",
"sa_player_penalty_info_no_active_warn": "➔ Warn [{lime}✔{default}]", "sa_player_penalty_info_no_active_warn": "➔ Warn [{lime}✔{default}]",
"sa_player_penalty_info": "===========================\nPlayer penalties for {lightred}{0}{default},\nNumber of bans: {lightred}{1}{default}, Number of gags: {lightred}{2}{default}, Number of mutes: {lightred}{3}{default}, Number of silences: {lightred}{4}{default}, Number of warnings: {lightred}{5}{default}\nActive penalties:\n{6}\nActive warnings:\n{7}\n===========================", "sa_player_penalty_info": "===========================\nPlayer penalties for {yellow}{0}{default},\nNumber of bans: {red}{1}{default}, Number of gags: {red}{2}{default}, Number of mutes: {red}{3}{default}, Number of silences: {red}{4}{default}, Number of warnings: {red}{5}\nActive penalties:\n{6}\nActive warnings:\n{7}\n===========================",
"sa_admin_penalty_info": "{grey}Player penalties for {lightred}{0}{grey}, Bans: {lightred}{1}{grey}, Gags: {lightred}{2}{grey}, Mutes: {lightred}{3}{grey}, Silences: {lightred}{4}{grey}, Warns: {lightred}{5}", "sa_admin_penalty_info": "Player penalties for {yellow}{0}{default}, Bans: {red}{1}{default}, Gags: {red}{2}{default}, Mutes: {red}{3}{default}, Silences: {red}{4}{default}, Warns: {red}{5}",
"sa_admin_associated_accounts": "{grey}Associated accounts of player {lightred}{0}{grey}: {1}", "sa_admin_associated_accounts": "Associated accounts of player {yellow}{0}{default}: {1}",
"sa_player_ban_message_time": "You have been banned for {lightred}{0}{default} for {lightred}{1}{default} minutes by {lightred}{2}{default}!", "sa_player_ban_message_time": "You have been banned for {lightred}{0}{default} for {lightred}{1}{default} minutes by {lightred}{2}{default}!",
"sa_player_ban_message_perm": "You have been banned permanently for {lightred}{0}{default} by {lightred}{1}{default}!", "sa_player_ban_message_perm": "You have been banned permanently for {lightred}{0}{default} by {lightred}{1}{default}!",
"sa_player_kick_message": "You have been kicked for {lightred}{0}{default} by {lightred}{1}{default}!", "sa_player_kick_message": "You have been kicked for {lightred}{0}{default} by {lightred}{1}{default}!",
@ -99,57 +99,60 @@
"sa_player_silence_message_perm": "You have been silenced permanently for {lightred}{0}{default} by {lightred}{1}{default}!", "sa_player_silence_message_perm": "You have been silenced permanently for {lightred}{0}{default} by {lightred}{1}{default}!",
"sa_player_warn_message_time": "You have been warned for {lightred}{0}{default} for {lightred}{1}{default} minutes by {lightred}{2}{default}!", "sa_player_warn_message_time": "You have been warned for {lightred}{0}{default} for {lightred}{1}{default} minutes by {lightred}{2}{default}!",
"sa_player_warn_message_perm": "You have been warned permanently for {lightred}{0}{default} by {lightred}{1}{default}!", "sa_player_warn_message_perm": "You have been warned permanently for {lightred}{0}{default} by {lightred}{1}{default}!",
"sa_admin_ban_message_time": "{lightred}{0}{default} banned {lightred}{1}{default} for {lightred}{2}{default} for {lightred}{3}{default} minutes!", "sa_admin_ban_message_time": "{red}{0}{default} banned {yellow}{1}{default} for {red}{2}{default} for {red}{3}{default} minutes",
"sa_admin_ban_message_perm": "{lightred}{0}{default} banned {lightred}{1}{default} permanently for {lightred}{2}{default}!", "sa_admin_ban_message_perm": "{red}{0}{default} banned {yellow}{1}{default} permanently for {red}{2}",
"sa_admin_kick_message": "{lightred}{0}{default} kicked {lightred}{1}{default} for {lightred}{2}{default}!", "sa_admin_kick_message": "{red}{0}{default} kicked {yellow}{1}{default} for {red}{2}",
"sa_admin_gag_message_time": "{lightred}{0}{default} gagged {lightred}{1}{default} for {lightred}{2}{default} for {lightred}{3}{default} minutes!", "sa_admin_gag_message_time": "{red}{0}{default} gagged {yellow}{1}{default} for {red}{2}{default} for {red}{3}{default} minutes",
"sa_admin_gag_message_perm": "{lightred}{0}{default} gagged {lightred}{1}{default} permanently for {lightred}{2}{default}!", "sa_admin_gag_message_perm": "{red}{0}{default} gagged {yellow}{1}{default} permanently for {red}{2}",
"sa_admin_mute_message_time": "{lightred}{0}{default} muted {lightred}{1}{default} for {lightred}{2}{default} for {lightred}{3}{default} minutes!", "sa_admin_mute_message_time": "{red}{0}{default} muted {yellow}{1}{default} for {red}{2}{default} for {red}{3}{default} minutes",
"sa_admin_mute_message_perm": "{lightred}{0}{default} muted {lightred}{1}{default} permanently for {lightred}{2}{default}!", "sa_admin_mute_message_perm": "{red}{0}{default} muted {yellow}{1}{default} permanently for {red}{2}",
"sa_admin_silence_message_time": "{lightred}{0}{default} silenced {lightred}{1}{default} for {lightred}{2}{default} for {lightred}{3}{default} minutes!", "sa_admin_silence_message_time": "{red}{0}{default} silenced {yellow}{1}{default} for {red}{2}{default} for {red}{3}{default} minutes",
"sa_admin_silence_message_perm": "{lightred}{0}{default} silenced {lightred}{1}{default} permanently for {lightred}{2}{default}!", "sa_admin_silence_message_perm": "{red}{0}{default} silenced {yellow}{1}{default} permanently for {red}{2}",
"sa_admin_warn_message_time": "{lightred}{0}{default} warned {lightred}{1}{default} for {lightred}{2}{default} for {lightred}{3}{default} minutes!", "sa_admin_warn_message_time": "{red}{0}{default} warned {yellow}{1}{default} for {red}{2}{default} for {red}{3}{default} minutes",
"sa_admin_warn_message_perm": "{lightred}{0}{default} warned {lightred}{1}{default} permanently for {lightred}{2}{default}!", "sa_admin_warn_message_perm": "{red}{0}{default} warned {yellow}{1}{default} permanently for {red}{2}{default}!",
"sa_admin_give_message": "{lightred}{0}{default} gave {lightred}{1}{default} a {lightred}{2}{default}!", "sa_admin_give_message": "{red}{0}{default} gave {yellow}{1}{default} a {grey}{2}",
"sa_admin_strip_message": "{lightred}{0}{default} took all of player {lightred}{1}{default} weapons!", "sa_admin_strip_message": "{red}{0}{default} took all of player {yellow}{1}{default} weapons",
"sa_admin_hp_message": "{lightred}{0}{default} changed {lightred}{1}{default} hp amount{default}!", "sa_admin_hp_message": "{red}{0}{default} changed {yellow}{1}{default} hp amount",
"sa_admin_speed_message": "{lightred}{0}{default} changed speed for {lightred}{1}{default}!", "sa_admin_speed_message": "{red}{0}{default} changed speed for {yellow}{1}",
"sa_admin_gravity_message": "{lightred}{0}{default} changed gravity for {lightred}{1}{default}!", "sa_admin_gravity_message": "{red}{0}{default} changed gravity for {yellow}{1}",
"sa_admin_money_message": "{lightred}{0}{default} changed money for {lightred}{1}{default}!", "sa_admin_money_message": "{red}{0}{default} changed money for {yellow}{1}",
"sa_admin_god_message": "{lightred}{0}{default} changed god mode for {lightred}{1}{default}!", "sa_admin_god_message": "{red}{0}{default} changed god mode for {yellow}{1}",
"sa_admin_resize_message": "{lightred}{0}{default} changed size for {lightred}{1}{default}!", "sa_admin_resize_message": "{red}{0}{default} changed size for {yellow}{1}",
"sa_admin_slay_message": "{lightred}{0}{default} slayed {lightred}{1}{default}!", "sa_admin_slay_message": "{red}{0}{default} slayed {yellow}{1}",
"sa_admin_slap_message": "{lightred}{0}{default} slapped {lightred}{1}{default}!", "sa_admin_slap_message": "{red}{0}{default} slapped {yellow}{1}",
"sa_admin_changemap_message": "{lightred}{0}{default} changed map to {lightred}{1}{default}!", "sa_admin_changemap_message": "{red}{0}{default} changed map to {green}{1}",
"sa_admin_noclip_message": "{lightred}{0}{default} toggled noclip for {lightred}{1}{default}!", "sa_admin_noclip_message": "{red}{0}{default} toggled noclip for {yellow}{1}",
"sa_admin_freeze_message": "{lightred}{0}{default} froze {lightred}{1}{default}!", "sa_admin_freeze_message": "{red}{0}{default} {blue}froze{default} {yellow}{1}",
"sa_admin_unfreeze_message": "{lightred}{0}{default} unfroze {lightred}{1}{default}!", "sa_admin_unfreeze_message": "{red}{0}{default} {blue}unfroze{default} {yellow}{1}",
"sa_admin_rename_message": "{lightred}{0}{default} changed {lightred}{1}{default} nickname to {lightred}{2}{default}!", "sa_admin_rename_message": "{red}{0}{default} changed {yellow}{1}{default} nickname to {yellow}{2}",
"sa_admin_respawn_message": "{lightred}{0}{default} respawned {lightred}{1}{default}!", "sa_admin_respawn_message": "{red}{0}{default} respawned {yellow}{1}",
"sa_admin_tp_message": "{lightred}{0}{default} teleported to {lightred}{1}{default}!", "sa_admin_tp_message": "{red}{0}{default} teleported to {yellow}{1}",
"sa_admin_bring_message": "{lightred}{0}{default} teleported to himself {lightred}{1}{default}!", "sa_admin_bring_message": "{red}{0}{default} teleported {yellow}{1}{default} to themselves",
"sa_admin_team_message": "{lightred}{0}{default} transfered {lightred}{1}{default} to {lightred}{2}{default}!", "sa_admin_team_message": "{red}{0}{default} transfered {yellow}{1}{default} to {grey}{2}",
"sa_admin_warns_menu_title": "{gold}{0} {lime}WARNS", "sa_admin_warns_menu_title": "{gold}{0} {lime}WARNS",
"sa_admin_warns_unwarn": "{lime}Successfully{default} unwarned {gold}{0} {default}for {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Successfully{default} unwarned {yellow}{0} {default}for {orange}{1}",
"sa_history": "History",
"sa_history_header": "===== Penalty history for {0} ({1}) - {2} record(s) =====",
"sa_history_none": "No penalties found for {0}.",
"sa_admin_vote_menu_title": "{lime}VOTING FOR {gold}{0}", "sa_admin_vote_menu_title": "{lime}VOTING FOR {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} started voting for {lightred}{1}{default}", "sa_admin_vote_message": "{red}{0}{default} started voting for {orange}{1}",
"sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {gold}{0}", "sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {orange}{0}",
"sa_admin_vote_message_results_answer": "{lime}{0} {default}- {gold}{1}", "sa_admin_vote_message_results_answer": "{lime}{0} {default}- {orange}{1}",
"sa_admin_voice_mute_all": "{Lightred}You muted {default}all players during your speech", "sa_admin_voice_mute_all": "{red}You muted {default}all players",
"sa_admin_voice_unmute_all": "{Lime}You unmuted {default}all players", "sa_admin_voice_unmute_all": "{lime}You unmuted {default}all players",
"sa_admin_voice_listen_all": "{Default}You can now hear {lime}all {default}players", "sa_admin_voice_listen_all": "You can now hear {lime}all {default}players",
"sa_admin_voice_unlisten_all": "{Default}You no longer hear {lime}all {default}players", "sa_admin_voice_unlisten_all": "You no longer hear {lime}all {default}players",
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", "sa_adminsay_prefix": "{lightred}{0}",
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_admin": ">[{yellow}STAFF{default}][{lime}{0}{default}] {grey}{1}",
"sa_staffchat_template": "{default}>[{yellow}STAFF{default}][{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}{default}", "sa_staffchat_template": ">[{yellow}STAFF{default}][{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}",
"sa_staffchat_template_norank": "{default}>[{yellow}STAFF{default}] {yellow}{0}{default}: {grey}{1}{default}", "sa_staffchat_template_norank": ">[{yellow}STAFF{default}] {yellow}{0}{default}: {grey}{1}",
"sa_staffchat_template_player": "{default}>[{purple}STAFF{default}] {purple}{0}{default}: {purple}{1}{default}", "sa_staffchat_template_player": ">[{red}STAFF{default}] {silver}{0}{default}: {silver}{1}",
"sa_staffsay_template": "[{lime}{0}{default}] {darkred}{1}{default}: {red}{2}{default}", "sa_staffsay_template": "{default}[{red}ALL{default}] [{lime}{0}{default}] {yellow}{1}{default}: {yellow}{2}{default}",
"sa_staffsay_template_norank": "{darkred}{0}{default}: {red}{1}{default}", "sa_staffsay_template_norank": "{yellow}{0}{default}: {red}{1}",
"sa_psay_template": "{default}(PM) [{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}{default}", "sa_psay_template": "[{red}PM{default}] [{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}",
"sa_psay_template_norank": "{default}(PM) {yellow}{0}{default}: {grey}{1}{default}", "sa_psay_template_norank": "[{red}PM{default}] {yellow}{0}{default}: {grey}{1}",
"sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}",
"sa_discord_log_command": "**{0}** issued command `{1}` on server `HOSTNAME`", "sa_discord_log_command": "**{0}** issued command `{1}` on server `HOSTNAME`",
"sa_menu_pluginsmanager_loaded": "{lime}Enabled {default}plugin {lime}{0}", "sa_menu_pluginsmanager_loaded": "{lime}Enabled {default}plugin {lime}{0}",
"sa_menu_pluginsmanager_unloaded": "{lightred}Disabled {default}plugin {lightred}{0}" "sa_menu_pluginsmanager_unloaded": "{red}Disabled {default}plugin {yellow}{0}"
} }

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} transfirió a {lightred}{1}{default} al {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} transfirió a {lightred}{1}{default} al {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS", "sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS",
"sa_admin_warns_unwarn": "{lime}Desadvertencia{default} exitosa de {gold}{0} {default}por {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Desadvertencia{default} exitosa de {gold}{0} {default}por {gold}{1}{default}!",
"sa_history": "Historial",
"sa_history_header": "===== Historial de sanciones de {0} ({1}) - {2} registro(s) =====",
"sa_history_none": "No se encontraron sanciones para {0}.",
"sa_admin_vote_menu_title": "{lime}VOTACIÓN PARA {gold}{0}", "sa_admin_vote_menu_title": "{lime}VOTACIÓN PARA {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} inició una votación para {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} inició una votación para {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}RESULTADOS DE LA VOTACIÓN PARA {gold}{0}", "sa_admin_vote_message_results": "{lime}RESULTADOS DE LA VOTACIÓN PARA {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default} را به {lightred}{2}{default} انتقال داد!", "sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default} را به {lightred}{2}{default} انتقال داد!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}هشدارها", "sa_admin_warns_menu_title": "{gold}{0} {lime}هشدارها",
"sa_admin_warns_unwarn": "{lime}هشدار با موفقیت{default} برای {gold}{0} {default}لغو شد به دلیل {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}هشدار با موفقیت{default} برای {gold}{0} {default}لغو شد به دلیل {gold}{1}{default}!",
"sa_history": "تاریخچه",
"sa_history_header": "===== تاریخچه مجازات‌های {0} ({1}) - {2} مورد =====",
"sa_history_none": "هیچ مجازاتی برای {0} یافت نشد.",
"sa_admin_vote_menu_title": "{lime}رأی‌گیری برای {gold}{0}", "sa_admin_vote_menu_title": "{lime}رأی‌گیری برای {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} رأی‌گیری برای {lightred}{1}{default} را شروع کرد", "sa_admin_vote_message": "{lightred}{0}{default} رأی‌گیری برای {lightred}{1}{default} را شروع کرد",
"sa_admin_vote_message_results": "{lime}نتایج رأی‌گیری برای {gold}{0}", "sa_admin_vote_message_results": "{lime}نتایج رأی‌گیری برای {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} a transféré {lightred}{1}{default} à l'équipe {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} a transféré {lightred}{1}{default} à l'équipe {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}AVERTISSEMENTS", "sa_admin_warns_menu_title": "{gold}{0} {lime}AVERTISSEMENTS",
"sa_admin_warns_unwarn": "{lime}Avertissement annulé{default} avec succès pour {gold}{0} {default}en raison de {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Avertissement annulé{default} avec succès pour {gold}{0} {default}en raison de {gold}{1}{default}!",
"sa_history": "Historique",
"sa_history_header": "===== Historique des sanctions de {0} ({1}) - {2} entrée(s) =====",
"sa_history_none": "Aucune sanction trouvée pour {0}.",
"sa_admin_vote_menu_title": "{lime}VOTE POUR {gold}{0}", "sa_admin_vote_menu_title": "{lime}VOTE POUR {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} a lancé un vote pour {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} a lancé un vote pour {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}RÉSULTATS DU VOTE POUR {gold}{0}", "sa_admin_vote_message_results": "{lime}RÉSULTATS DU VOTE POUR {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} pārvietoja {lightred}{1}{default} uz {lightred}{2}{default} komandu!", "sa_admin_team_message": "{lightred}{0}{default} pārvietoja {lightred}{1}{default} uz {lightred}{2}{default} komandu!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}BRĪDINĀJUMI", "sa_admin_warns_menu_title": "{gold}{0} {lime}BRĪDINĀJUMI",
"sa_admin_warns_unwarn": "{lime}Brīdinājums veiksmīgi{default} atsaukts priekš {gold}{0} {default}dēļ {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Brīdinājums veiksmīgi{default} atsaukts priekš {gold}{0} {default}dēļ {gold}{1}{default}!",
"sa_history": "Vēsture",
"sa_history_header": "===== Sodu vēsture spēlētājam {0} ({1}) - {2} ieraksts(-i) =====",
"sa_history_none": "Spēlētājam {0} nav atrasts neviens sods.",
"sa_admin_vote_menu_title": "{lime}BALSOŠANA PAR {gold}{0}", "sa_admin_vote_menu_title": "{lime}BALSOŠANA PAR {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} uzsāka balsošanu par {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} uzsāka balsošanu par {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}BALSOŠANAS REZULTĀTI PAR {gold}{0}", "sa_admin_vote_message_results": "{lime}BALSOŠANAS REZULTĀTI PAR {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} przerzucił {lightred}{1}{default} do {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} przerzucił {lightred}{1}{default} do {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{lime}OSTRZEŻENIA {gold}{0}", "sa_admin_warns_menu_title": "{lime}OSTRZEŻENIA {gold}{0}",
"sa_admin_warns_unwarn": "{lime}Pomyślnie{default} usunięto ostrzeżenie dla {gold}{0} {default}za {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Pomyślnie{default} usunięto ostrzeżenie dla {gold}{0} {default}za {gold}{1}{default}!",
"sa_history": "Historia",
"sa_history_header": "===== Historia kar gracza {0} ({1}) - {2} wpis(ow) =====",
"sa_history_none": "Nie znaleziono kar dla {0}.",
"sa_admin_vote_menu_title": "{lime}GŁOSOWANIE NA {gold}{0}", "sa_admin_vote_menu_title": "{lime}GŁOSOWANIE NA {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}WYNIKI GŁOSOWANIA {gold}{0}", "sa_admin_vote_message_results": "{lime}WYNIKI GŁOSOWANIA {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS", "sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS",
"sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!",
"sa_history": "Histórico",
"sa_history_header": "===== Histórico de punições de {0} ({1}) - {2} registro(s) =====",
"sa_history_none": "Nenhuma punição encontrada para {0}.",
"sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}", "sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}", "sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS", "sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS",
"sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!",
"sa_history": "Histórico",
"sa_history_header": "===== Histórico de punições de {0} ({1}) - {2} registo(s) =====",
"sa_history_none": "Nenhuma punição encontrada para {0}.",
"sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}", "sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}", "sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} перевел {lightred}{1}{default} в команду {lightred}{2}{default}!", "sa_admin_team_message": "{lightred}{0}{default} перевел {lightred}{1}{default} в команду {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ", "sa_admin_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ",
"sa_admin_warns_unwarn": "{lime}Предупреждение успешно удалено{default} для {gold}{0} {default}за {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Предупреждение успешно удалено{default} для {gold}{0} {default}за {gold}{1}{default}!",
"sa_history": "История",
"sa_history_header": "===== История наказаний игрока {0} ({1}) - записей: {2} =====",
"sa_history_none": "Наказания для {0} не найдены.",
"sa_admin_vote_menu_title": "{lime}ГОЛОСОВАНИЕ ЗА {gold}{0}", "sa_admin_vote_menu_title": "{lime}ГОЛОСОВАНИЕ ЗА {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}", "sa_admin_vote_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}РЕЗУЛЬТАТЫ ГОЛОСОВАНИЯ ЗА {gold}{0}", "sa_admin_vote_message_results": "{lime}РЕЗУЛЬТАТЫ ГОЛОСОВАНИЯ ЗА {gold}{0}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default}'i {lightred}{2}{default} takımına transfer etti!", "sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default}'i {lightred}{2}{default} takımına transfer etti!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}UYARILAR", "sa_admin_warns_menu_title": "{gold}{0} {lime}UYARILAR",
"sa_admin_warns_unwarn": "{lime}Uyarı başarıyla kaldırıldı{default} {gold}{0} {default} için {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}Uyarı başarıyla kaldırıldı{default} {gold}{0} {default} için {gold}{1}{default}!",
"sa_history": "Geçmiş",
"sa_history_header": "===== {0} ({1}) ceza geçmişi - {2} kayıt =====",
"sa_history_none": "{0} için ceza bulunamadı.",
"sa_admin_vote_menu_title": "{lime}OY VERME {gold}{0}", "sa_admin_vote_menu_title": "{lime}OY VERME {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı", "sa_admin_vote_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı",
"sa_admin_vote_message_results": "{lime}OY SONUÇLARI {gold}{0}", "sa_admin_vote_message_results": "{lime}OY SONUÇLARI {gold}{0}",

View file

@ -120,6 +120,9 @@
"sa_admin_team_message": "{lightred}{0}{default} 将 {lightred}{1}{default} 转移到 {lightred}{2}{default} 队伍!", "sa_admin_team_message": "{lightred}{0}{default} 将 {lightred}{1}{default} 转移到 {lightred}{2}{default} 队伍!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}警告", "sa_admin_warns_menu_title": "{gold}{0} {lime}警告",
"sa_admin_warns_unwarn": "{lime}成功移除警告{default} {gold}{0} 对于 {gold}{1}{default}!", "sa_admin_warns_unwarn": "{lime}成功移除警告{default} {gold}{0} 对于 {gold}{1}{default}!",
"sa_history": "历史记录",
"sa_history_header": "===== {0} ({1}) 的处罚记录 - 共 {2} 条 =====",
"sa_history_none": "未找到 {0} 的处罚记录。",
"sa_admin_vote_menu_title": "{lime}投票 {gold}{0}", "sa_admin_vote_menu_title": "{lime}投票 {gold}{0}",
"sa_admin_vote_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票", "sa_admin_vote_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票",
"sa_admin_vote_message_results": "{lime}投票结果 {gold}{0}", "sa_admin_vote_message_results": "{lime}投票结果 {gold}{0}",

View file

@ -14,8 +14,8 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code:
- New permission `@css/bypassipban`, for players or groups: IP bans, and bans on other accounts - New permission `@css/bypassipban`, for players or groups: IP bans, and bans on other accounts
that share their IP, don't apply to them; a ban on their own SteamID still does. `@css/root` that share their IP, don't apply to them; a ban on their own SteamID still does. `@css/root`
has it, as it has every `@css` flag. has it, as it has every `@css` flag.
- Staff chat shows the sender's rank, from their highest `#rank/...` group (O, SA, A, TA, M, TM, - Staff chat shows the sender's rank, from their highest `#rank/...` group (O, S.Admin, Admin,
H for helper, G for guardian): `@message` in team chat and `css_asay` read T.Admin, Mod, T.Mod, Helper, Guardian, the same as CS2-Tags' chat prefixes): `@message` in team chat and `css_asay` read
`>[STAFF][RANK] name: message`, and `>[STAFF][RANK] name: message`, and
`@message` in all chat and `css_say` from a player read `[RANK] name: message` (`name: message` `@message` in all chat and `css_say` from a player read `[RANK] name: message` (`name: message`
if they have no rank group). A non-staff player's `@message` in team chat reads if they have no rank group). A non-staff player's `@message` in team chat reads
@ -24,6 +24,39 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code:
`sa_staffchat_template_player`, in `lang/en.json` only. `sa_staffchat_template_player`, in `lang/en.json` only.
- `css_psay` from a player reads `(PM) [RANK] name: message` (`(PM) name: message` without a rank - `css_psay` from a player reads `(PM) [RANK] name: message` (`(PM) name: message` without a rank
group); console's is unchanged. Lang keys `sa_psay_template` and `sa_psay_template_norank`. group); console's is unchanged. Lang keys `sa_psay_template` and `sa_psay_template_norank`.
- `admin_overrides.json` reaches the admin menu too: every player and server entry names its command,
and an overridden entry is checked with the override's `check_type` (`any` passes with one flag),
as CSS checks the typed command. Upstream required every flag in the override.
- `css_penalties <player>` (someone else's penalties) needs `@zio/penalties` or `@css/root`
instead of `@css/kick`.
- `css_addadmin`, `css_deladmin` and the admin menu's add/remove log rank changes (the player's
highest-immunity group, and who changed it) to `sa_rank_log`, the table simpleadmin-web creates
and lists as the public promotions/demotions log. If the table is missing, it warns once in the
log and the admin change goes ahead.
- With `TimeMode` 0 (online time only), a gag, mute or silence given in game now ends when its
database row expires. Upstream matched the two by exact end time, which never matched for one
given in game (the database stores the time to the second), so it lasted until the player
reconnected. A penalty marked passed also stops applying at once, not on the next 61-second tick.
- Targeting by part of a name: when several names contain it, one that is exactly it (any case)
is picked, and otherwise the reply lists the matches. `css_ungag`, `css_unmute` and
`css_unsilence` match online players by part of the name too (upstream needed the full name),
falling back to the offline search only when no one online matches.
- No startup version check or banner: upstream's `PluginInfo` (`CheckVersion`, which asked upstream's
GitHub for its `VERSION` on every load, and `ShowAd`, the console banner with two encoded lines
of personal messages) is removed. `EnableUpdateCheck` stays in the config, so existing files
still load, and does nothing.
Backported from upstream 1.9.0 (commit `530f603`), not marked `zio:`:
- `css_unban` applies at once: the ban cache is updated straight away instead of on the next
61-second refresh, and `removeid` clears the engine ban when `UnlockedCommands` is on.
- `css_prename` survives restarts: renames are stored in the new `sa_renames` table (migration
017) and re-applied on connect and name change. `css_rename` from a hidden admin renames again.
- New `css_history <#userid|name|steamid64> [bans|gags|mutes|silences|warns]` (`@css/kick`), and
Players > History in the admin menu. Commands missing from an existing `Commands.json` are
registered under their default name.
- Left out: upstream's admin-loading changes (ours differ, see `1.8.2b-zio11`), its `css_say`
change and its `css_penalties` fix.
Publish with `FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>` (ModuleVersion is `1.8.2b-zioN`). Publish with `FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>` (ModuleVersion is `1.8.2b-zioN`).
It rebuilds from the committed source and uploads `SimpleAdmin-<tag>.tar.gz` to git.zio.sh It rebuilds from the committed source and uploads `SimpleAdmin-<tag>.tar.gz` to git.zio.sh

View file

@ -22,8 +22,13 @@ set -euo pipefail
FORGEJO_URL="https://git.zio.sh" FORGEJO_URL="https://git.zio.sh"
OWNER="cs2" OWNER="cs2"
REPO="SimpleAdmin" REPO="SimpleAdmin"
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository. # Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token. # 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}" FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
TAG="${1:-}" TAG="${1:-}"
@ -32,7 +37,7 @@ if [[ -z "$TAG" ]]; then
exit 1 exit 1
fi fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then 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 exit 1
fi fi
for tool in podman jq curl git; do for tool in podman jq curl git; do