Compare commits

..

10 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
42 changed files with 858 additions and 410 deletions

1
.gitignore vendored
View file

@ -14,3 +14,4 @@ CLAUDE.md
/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN
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 information about all online players.

View file

@ -251,9 +251,9 @@ css_prename PlayerName "NewIdentity"
```
**Notes:**
- Name is enforced even after reconnect
- Stored in database
- Player cannot change it
- Name is enforced even after reconnect, map change and server restart
- Stored in the database (`sa_renames`) and shared by every server using that database
- Player cannot change it; `css_prename <player>` with no name removes the rename
- 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 ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
public override string ModuleAuthor => "daffyy";
public override string ModuleVersion => "1.8.2b-zio9";
public override string ModuleVersion => "1.8.2b-zio17";
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))
DiscordWebhookClientLog = new DiscordManager(Config.Discord.DiscordLogWebhook);
if (Config.EnableUpdateCheck)
Task.Run(async () => await PluginInfo.CheckVersion(ModuleVersion, Logger));
// zio: no update check or startup banner (upstream's PluginInfo, removed). EnableUpdateCheck
// is left in the config so existing files still load, and does nothing.
PermissionManager = new PermissionManager(DatabaseProvider);
BanManager = new BanManager(DatabaseProvider);
@ -242,7 +242,13 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
if (matches.Count() == 1)
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;
}

View file

@ -36,106 +36,10 @@
</ItemGroup>
<ItemGroup>
<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>
<None Update="Database\Migrations\*.sql" CopyToOutputDirectory="PreserveNewest" />
<None Update="Database\Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -45,6 +45,7 @@ public static class RegisterCommands
new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand),
new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand),
new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand),
new("css_history", CS2_SimpleAdmin.Instance.OnHistoryCommand),
new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand),
new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand),
new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand),
@ -130,6 +131,7 @@ public static class RegisterCommands
{ "css_who", new Command { Aliases = ["css_who"] } },
{ "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } },
{ "css_warns", new Command { Aliases = ["css_warns"] } },
{ "css_history", new Command { Aliases = ["css_history", "css_penaltyhistory"] } },
{ "css_players", new Command { Aliases = ["css_players"] } },
{ "css_kick", new Command { Aliases = ["css_kick"] } },
{ "css_map", new Command { Aliases = ["css_map", "css_changemap"] } },
@ -198,6 +200,13 @@ public static class RegisterCommands
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)

View file

@ -32,7 +32,7 @@ public partial class CS2_SimpleAdmin
if (_localizer == null) continue;
// zio: players get ">[STAFF][RANK] name: message", like @ in team chat.
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()));
}
}
@ -101,7 +101,7 @@ public partial class CS2_SimpleAdmin
{
player.SendLocalizedMessage(_localizer,
"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".
var rank = caller == null ? null : Helper.GetStaffRankTag(caller);
var line = caller == null || _localizer == null
? $"({callerName}) {utf8String}".ReplaceColorTags()
? $"({callerName}) {utf8String.Trim()}".ReplaceColorTags()
: rank == null
? _localizer["sa_psay_template_norank", callerName, utf8String.Trim()]
: _localizer["sa_psay_template", rank, callerName, utf8String.Trim()];

View file

@ -286,7 +286,9 @@ public partial class CS2_SimpleAdmin
if (DatabaseProvider == null) return;
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}");
@ -331,7 +333,8 @@ public partial class CS2_SimpleAdmin
public void RemoveAdmin(CCSPlayerController? caller, string steamid, bool globalDelete = false, CommandInfo? command = null)
{
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, () =>
{
@ -504,24 +507,29 @@ public partial class CS2_SimpleAdmin
Task.Run(async () =>
{
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, () =>
{
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(2.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
if (!string.IsNullOrEmpty(groupsFile))
AddTimer(3.0f, () => AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json"));
if (!string.IsNullOrEmpty(adminsFile))
AddTimer(4.0f, () => AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"));
// 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,
// all in one tick so nobody is left without their flags in between. Groups can't be
// removed from CSS, so flags taken off a group still need a restart.
foreach (var steamId in PermissionManager.AdminCache.Keys.ToList())
AdminManager.RemovePlayerAdminData(steamId);
PermissionManager.AdminCache.Clear();
_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);
});
});
@ -838,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>
/// Lists players currently connected to the server with options to output JSON or filter duplicate IPs.
/// </summary>

View file

@ -292,6 +292,13 @@ public partial class CS2_SimpleAdmin
var nameMatches = Helper.GetPlayerFromName(pattern);
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)
{
PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Gag);
@ -604,6 +611,13 @@ public partial class CS2_SimpleAdmin
var nameMatches = Helper.GetPlayerFromName(pattern);
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)
{
PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Mute);
@ -919,6 +933,13 @@ public partial class CS2_SimpleAdmin
var nameMatches = Helper.GetPlayerFromName(pattern);
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)
{
PlayerPenaltyManager.RemovePenaltiesByType(namePlayer.Slot, PenaltyType.Silence);

View file

@ -292,9 +292,8 @@ public partial class CS2_SimpleAdmin
var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName };
// Display admin activity message to other players
if (caller != null && SilentPlayers.Contains(caller.Slot)) return;
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
if (caller != null && !SilentPlayers.Contains(caller.Slot))
Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs);
// Rename the player
player.Rename(newName);
@ -344,15 +343,18 @@ public partial class CS2_SimpleAdmin
}
// Determine if the new name is valid and update the renamed players list
var steamId = player.SteamID;
if (!string.IsNullOrEmpty(newName))
{
RenamedPlayers[player.SteamID] = newName;
RenamedPlayers[steamId] = newName;
player.Rename(newName);
}
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 GetExpireOldPlayerIpsQuery();
// Renames (css_prename)
string GetRenamesQuery();
string GetUpsertRenameQuery();
string GetDeleteRenameQuery();
// MuteManager
string GetAddMuteQuery(bool includePlayerName);
string GetIsMutedQuery(bool multiServer, int timeMode);
@ -61,4 +66,7 @@ public interface IDatabaseProvider
string GetUnwarnByIdQuery(bool multiServer);
string GetUnwarnLastQuery(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)
{
return multiServer
? "SELECT id 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'"
: "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()
@ -238,6 +238,15 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
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)
{
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 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;
}

View file

@ -143,8 +143,8 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetUnbanRetrieveBansQuery(bool multiServer) =>
multiServer
? "SELECT id 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'"
: "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() =>
"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) 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() =>
"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) =>
multiServer
? "UPDATE sa_bans SET status = 'EXPIRED' 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"
: "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) =>
multiServer
@ -170,6 +171,15 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetExpireOldPlayerIpsQuery() =>
"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() =>
"""
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
? "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";
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]
public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _)
{
var player = @event.Userid;
if (player is null || !player.IsValid || player.IsBot)
return HookResult.Continue;
if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue;
if (player.PlayerName.Equals(name))
return HookResult.Continue;
player.Rename(name);
EnforceRename(@event.Userid);
return HookResult.Continue;
}
[GameEventHandler]
public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _)
{
EnforceRename(@event.Userid);
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);
});
}
}

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)
{
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)
@ -158,21 +161,21 @@ internal static class Helper
return check.CanExecuteCommand(player);
}
// zio: rank groups, highest first, and their chat abbreviations. Guardian (VIP) is last, so a
// staff rank wins.
// 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 =
[
("#rank/owner", "O"),
("#rank/senioradmin", "SA"),
("#rank/admin", "A"),
("#rank/trialadmin", "TA"),
("#rank/mod", "M"),
("#rank/trialmod", "TM"),
("#rank/helper", "H"),
("#rank/guardian", "G"),
("#rank/senioradmin", "S.Admin"),
("#rank/admin", "Admin"),
("#rank/trialadmin", "T.Admin"),
("#rank/mod", "Mod"),
("#rank/trialmod", "T.Mod"),
("#rank/helper", "Helper"),
("#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)
{
var groups = AdminManager.GetPlayerAdminData(new SteamID(player.SteamID))?.Groups;
@ -1003,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)
: NativeObject(Schema.GetSchemaValue<nint>(instance.Handle, typeof(TSchemaClass).Name, member))
where TSchemaClass : NativeObject

View file

@ -1,5 +1,6 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CounterStrikeSharp.API.Modules.Entities;
using CS2_SimpleAdminApi;
using Dapper;
using Microsoft.Extensions.Logging;
@ -339,8 +340,34 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string
var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery();
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)

View file

@ -10,8 +10,10 @@ namespace CS2_SimpleAdmin.Managers;
internal class CacheManager: IDisposable
{
private readonly ConcurrentDictionary<int, BanRecord> _banCache = [];
private readonly ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = [];
private readonly ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = [];
// Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index
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 HashSet<uint> _cachedIgnoredIps = [];
@ -178,7 +180,7 @@ internal class CacheManager: IDisposable
player_steamid AS PlayerSteamId,
player_ip AS PlayerIp,
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 }
)).ToList();
@ -221,7 +223,7 @@ internal class CacheManager: IDisposable
player_steamid AS PlayerSteamId,
player_ip AS PlayerIp,
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 }
)).ToList();
@ -294,7 +296,7 @@ internal class CacheManager: IDisposable
.GroupBy(x => x.Address)
.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(
g.Key,
latest.Used_at,
@ -353,8 +355,36 @@ internal class CacheManager: IDisposable
/// </summary>
private void RebuildIndexes()
{
_steamIdIndex.Clear();
_ipIndex.Clear();
// SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here
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
var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType;
@ -369,10 +399,10 @@ internal class CacheManager: IDisposable
if (ban.PlayerSteamId.HasValue)
{
var steamId = ban.PlayerSteamId.Value;
if (!_steamIdIndex.TryGetValue(steamId, out var steamList))
if (!steamIdIndex.TryGetValue(steamId, out var steamList))
{
steamList = new List<BanRecord>();
_steamIdIndex[steamId] = steamList;
steamIdIndex[steamId] = steamList;
}
steamList.Add(ban);
}
@ -381,14 +411,18 @@ internal class CacheManager: IDisposable
if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) &&
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>();
_ipIndex[ipUInt] = ipList;
ipIndex[ipUInt] = ipList;
}
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>

View file

@ -212,7 +212,15 @@ internal class MuteManager(IDatabaseProvider? databaseProvider)
foreach (var muteRecord in muteRecords)
{
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>
/// Retrieves all players' flags and associated data asynchronously.
/// </summary>
/// <returns>A list of tuples containing player SteamID, name, flags, immunity, and expiration time.</returns>
private async Task<List<(ulong, string ,List<string>, int, DateTime?)>> GetAllPlayersFlags()
/// <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()
{
if (databaseProvider == null)
return new List<(ulong, string, List<string>, int, DateTime?)>();
@ -68,7 +68,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
catch (Exception ex)
{
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,96 +151,50 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// <summary>
/// Creates a JSON file containing admins data asynchronously.
/// </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>")]
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
.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();
var jsonData = validPlayers
.GroupBy(player => player.name)
.ToDictionary(
group => group.Key,
object (group) =>
{
var consolidatedData = group.Aggregate(
new
{
identity = string.Empty,
immunity = 0,
flags = new List<string>(),
groups = new List<string>()
},
(acc, player) =>
{
if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString()))
{
acc = acc with { identity = player.identity.ToString() };
}
// 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
.GroupBy(p => p.steamId!.SteamId64)
.ToDictionary(
group => group.Key.ToString(),
object (group) =>
{
var rows = group.Select(p => p.player).ToList();
var flags = rows.SelectMany(r => r.Item3).Distinct().ToList();
return new
{
identity = group.Key.ToString(),
immunity = rows.Max(r => r.Item4),
flags = flags.Where(flag => flag.StartsWith($"@")).ToList(),
groups = flags.Where(flag => flag.StartsWith($"#")).ToList()
};
});
acc = acc with { immunity = Math.Max(acc.immunity, player.immunity) };
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
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
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var json = JsonSerializer.Serialize(jsonData, options);
var json = JsonSerializer.Serialize(jsonData, options);
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
await File.WriteAllTextAsync(filePath, json);
return validPlayers.Select(p => (p.steamId!, p.player.Item5, p.player.Item3)).ToList();
}
/// <summary>
@ -247,7 +202,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// </summary>
/// <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>
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 (string.IsNullOrEmpty(playerSteamId)) return;
@ -255,8 +212,14 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
try
{
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);
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)
{
@ -273,7 +236,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// <param name="immunity">Immunity level.</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>
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;
@ -285,6 +250,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
try
{
await using var connection = await databaseProvider.CreateConnectionAsync();
var before = await GetRank(connection, playerSteamId); // zio: for the rank log
var insertAdminSql = databaseProvider.GetAddAdminQuery();
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(() =>
{
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>
/// Adds a new group with flags and immunity asynchronously.
/// </summary>

View file

@ -1,3 +1,4 @@
using System.Globalization;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
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.
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;
Task.Run(async () =>
@ -297,10 +303,92 @@ internal class PlayerManager
_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)
{
if (CS2_SimpleAdmin.DatabaseProvider == null) return [];
try
{
player.Rename(name);
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 (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;
}
@ -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>
/// Removes or expires penalties automatically across all players based on their duration or "passed" flag.
/// </summary>

View file

@ -43,6 +43,7 @@ public class ServerManager
if (_getIpTryCount > 32 && isInvalidIp)
{
CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!");
_ = CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers();
return;
}
@ -103,7 +104,7 @@ public class ServerManager
CS2_SimpleAdmin.ServerId = serverId;
CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress);
CS2_SimpleAdmin.ServerLoaded = true;
CS2_SimpleAdmin.ServerLoaded = true;
if (CS2_SimpleAdmin.Instance.CacheManager != null)
{
@ -115,6 +116,9 @@ CS2_SimpleAdmin.ServerLoaded = true;
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)
{
var queryString = $"?address={address}&hostname={hostname}";

View file

@ -30,6 +30,7 @@ public abstract class BasicMenu
manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat", "css_mute");
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
manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root");
@ -128,6 +129,62 @@ public abstract class BasicMenu
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>
/// Creates menu for kicking players with reason selection.
/// </summary>

View file

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

View file

@ -1,5 +1,6 @@
{GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT}
- 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_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

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات",
"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_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}",
"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_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_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_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet",
"sa_admin_vote_message_results": "{lime}ABSTIMMUNGSERGEBNISSE FÜR {gold}{0}",

View file

@ -1,10 +1,10 @@
{
"sa_title": "SimpleAdmin",
"sa_prefix": "{lightred}[SA] {default}",
"sa_prefix": "{default}[{red}zio{default}] ",
"sa_unknown": "Unknown",
"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_admin_add": "Add Admin",
@ -64,30 +64,30 @@
"sa_discord_penalty_warn": "Warn registered",
"sa_discord_penalty_unknown": "Unknown registered",
"sa_player_penalty_chat_active": "{lightred}Your chat is blocked to: {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_perm": "{lightred}You are gagged permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.",
"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_perm": "{lightred}You are silenced permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.",
"sa_player_penalty_chat_active": "{red}Your chat is blocked until: {grey}{0}",
"sa_player_penalty_gag_active": "You are gagged for {red}{0}{default}.\nReason: {red}{1}",
"sa_player_penalty_gag_active_perm": "You are gagged {red}permanently{default}.\nReason: {red}{0}",
"sa_player_penalty_silence_active": "You are silenced for {red}{0}{default}.\nReason: {red}{1}",
"sa_player_penalty_silence_active_perm": "You are silenced {red}permanently{default}.\nReason: {red}{0}",
"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_perm": "{lightred}You are banned from this server permanently. Reason: {grey}{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_kick": "{grey}You will be disconnected in a few seconds.",
"sa_player_ban_active": "You are banned from this server for {red}{0}{default}.\nReason: {red}{1}",
"sa_player_ban_active_perm": "You are banned from this server {red}permanently{default}.\nReason: {red}{0}",
"sa_player_ban_active_other_account": "The ban is on another account that has played from your IP address.",
"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_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]",
"sa_player_penalty_info_active_silence": "➔ Silence [{lightred}❌{default}] - Expire [{lightred}{0}{default}]",
"sa_player_penalty_info_active_warn": "➔ Warn [{lightred}❌{default}] - Expire [{lightred}{0}{default}] - Reason [{lightred}{1}{default}]",
"sa_player_penalty_info_active_mute": "➔ Mute [{lightred}❌{default}] - Expire [{red}{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 [{red}{0}{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_gag": "➔ Gag [{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": "===========================\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_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_associated_accounts": "{grey}Associated accounts of player {lightred}{0}{grey}: {1}",
"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": "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": "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_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}!",
@ -99,57 +99,60 @@
"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_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_perm": "{lightred}{0}{default} banned {lightred}{1}{default} permanently for {lightred}{2}{default}!",
"sa_admin_kick_message": "{lightred}{0}{default} kicked {lightred}{1}{default} for {lightred}{2}{default}!",
"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_perm": "{lightred}{0}{default} gagged {lightred}{1}{default} permanently for {lightred}{2}{default}!",
"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_perm": "{lightred}{0}{default} muted {lightred}{1}{default} permanently for {lightred}{2}{default}!",
"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_perm": "{lightred}{0}{default} silenced {lightred}{1}{default} permanently for {lightred}{2}{default}!",
"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_perm": "{lightred}{0}{default} warned {lightred}{1}{default} permanently for {lightred}{2}{default}!",
"sa_admin_give_message": "{lightred}{0}{default} gave {lightred}{1}{default} a {lightred}{2}{default}!",
"sa_admin_strip_message": "{lightred}{0}{default} took all of player {lightred}{1}{default} weapons!",
"sa_admin_hp_message": "{lightred}{0}{default} changed {lightred}{1}{default} hp amount{default}!",
"sa_admin_speed_message": "{lightred}{0}{default} changed speed for {lightred}{1}{default}!",
"sa_admin_gravity_message": "{lightred}{0}{default} changed gravity for {lightred}{1}{default}!",
"sa_admin_money_message": "{lightred}{0}{default} changed money for {lightred}{1}{default}!",
"sa_admin_god_message": "{lightred}{0}{default} changed god mode for {lightred}{1}{default}!",
"sa_admin_resize_message": "{lightred}{0}{default} changed size for {lightred}{1}{default}!",
"sa_admin_slay_message": "{lightred}{0}{default} slayed {lightred}{1}{default}!",
"sa_admin_slap_message": "{lightred}{0}{default} slapped {lightred}{1}{default}!",
"sa_admin_changemap_message": "{lightred}{0}{default} changed map to {lightred}{1}{default}!",
"sa_admin_noclip_message": "{lightred}{0}{default} toggled noclip for {lightred}{1}{default}!",
"sa_admin_freeze_message": "{lightred}{0}{default} froze {lightred}{1}{default}!",
"sa_admin_unfreeze_message": "{lightred}{0}{default} unfroze {lightred}{1}{default}!",
"sa_admin_rename_message": "{lightred}{0}{default} changed {lightred}{1}{default} nickname to {lightred}{2}{default}!",
"sa_admin_respawn_message": "{lightred}{0}{default} respawned {lightred}{1}{default}!",
"sa_admin_tp_message": "{lightred}{0}{default} teleported to {lightred}{1}{default}!",
"sa_admin_bring_message": "{lightred}{0}{default} teleported to himself {lightred}{1}{default}!",
"sa_admin_team_message": "{lightred}{0}{default} transfered {lightred}{1}{default} to {lightred}{2}{default}!",
"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": "{red}{0}{default} banned {yellow}{1}{default} permanently for {red}{2}",
"sa_admin_kick_message": "{red}{0}{default} kicked {yellow}{1}{default} for {red}{2}",
"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": "{red}{0}{default} gagged {yellow}{1}{default} permanently for {red}{2}",
"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": "{red}{0}{default} muted {yellow}{1}{default} permanently for {red}{2}",
"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": "{red}{0}{default} silenced {yellow}{1}{default} permanently for {red}{2}",
"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": "{red}{0}{default} warned {yellow}{1}{default} permanently for {red}{2}{default}!",
"sa_admin_give_message": "{red}{0}{default} gave {yellow}{1}{default} a {grey}{2}",
"sa_admin_strip_message": "{red}{0}{default} took all of player {yellow}{1}{default} weapons",
"sa_admin_hp_message": "{red}{0}{default} changed {yellow}{1}{default} hp amount",
"sa_admin_speed_message": "{red}{0}{default} changed speed for {yellow}{1}",
"sa_admin_gravity_message": "{red}{0}{default} changed gravity for {yellow}{1}",
"sa_admin_money_message": "{red}{0}{default} changed money for {yellow}{1}",
"sa_admin_god_message": "{red}{0}{default} changed god mode for {yellow}{1}",
"sa_admin_resize_message": "{red}{0}{default} changed size for {yellow}{1}",
"sa_admin_slay_message": "{red}{0}{default} slayed {yellow}{1}",
"sa_admin_slap_message": "{red}{0}{default} slapped {yellow}{1}",
"sa_admin_changemap_message": "{red}{0}{default} changed map to {green}{1}",
"sa_admin_noclip_message": "{red}{0}{default} toggled noclip for {yellow}{1}",
"sa_admin_freeze_message": "{red}{0}{default} {blue}froze{default} {yellow}{1}",
"sa_admin_unfreeze_message": "{red}{0}{default} {blue}unfroze{default} {yellow}{1}",
"sa_admin_rename_message": "{red}{0}{default} changed {yellow}{1}{default} nickname to {yellow}{2}",
"sa_admin_respawn_message": "{red}{0}{default} respawned {yellow}{1}",
"sa_admin_tp_message": "{red}{0}{default} teleported to {yellow}{1}",
"sa_admin_bring_message": "{red}{0}{default} teleported {yellow}{1}{default} to themselves",
"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_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_message": "{lightred}{0}{default} started voting for {lightred}{1}{default}",
"sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {gold}{0}",
"sa_admin_vote_message_results_answer": "{lime}{0} {default}- {gold}{1}",
"sa_admin_voice_mute_all": "{Lightred}You muted {default}all players during your speech",
"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_unlisten_all": "{Default}You no longer hear {lime}all {default}players",
"sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}",
"sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}",
"sa_staffchat_template": "{default}>[{yellow}STAFF{default}][{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}{default}",
"sa_staffchat_template_norank": "{default}>[{yellow}STAFF{default}] {yellow}{0}{default}: {grey}{1}{default}",
"sa_staffchat_template_player": "{default}>[{purple}STAFF{default}] {purple}{0}{default}: {purple}{1}{default}",
"sa_staffsay_template": "{default}[{lime}{0}{default}] {lime}{1}{default}: {gold}{2}{default}",
"sa_staffsay_template_norank": "{darkred}{0}{default}: {red}{1}{default}",
"sa_psay_template": "{default}(PM) [{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}{default}",
"sa_psay_template_norank": "{default}(PM) {yellow}{0}{default}: {grey}{1}{default}",
"sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}{default}",
"sa_admin_vote_message": "{red}{0}{default} started voting for {orange}{1}",
"sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {orange}{0}",
"sa_admin_vote_message_results_answer": "{lime}{0} {default}- {orange}{1}",
"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_listen_all": "You can now hear {lime}all {default}players",
"sa_admin_voice_unlisten_all": "You no longer hear {lime}all {default}players",
"sa_adminsay_prefix": "{lightred}{0}",
"sa_adminchat_template_admin": ">[{yellow}STAFF{default}][{lime}{0}{default}] {grey}{1}",
"sa_staffchat_template": ">[{yellow}STAFF{default}][{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}",
"sa_staffchat_template_norank": ">[{yellow}STAFF{default}] {yellow}{0}{default}: {grey}{1}",
"sa_staffchat_template_player": ">[{red}STAFF{default}] {silver}{0}{default}: {silver}{1}",
"sa_staffsay_template": "{default}[{red}ALL{default}] [{lime}{0}{default}] {yellow}{1}{default}: {yellow}{2}{default}",
"sa_staffsay_template_norank": "{yellow}{0}{default}: {red}{1}",
"sa_psay_template": "[{red}PM{default}] [{lime}{0}{default}] {yellow}{1}{default}: {grey}{2}",
"sa_psay_template_norank": "[{red}PM{default}] {yellow}{0}{default}: {grey}{1}",
"sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}",
"sa_discord_log_command": "**{0}** issued command `{1}` on server `HOSTNAME`",
"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_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS",
"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_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}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default} را به {lightred}{2}{default} انتقال داد!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}هشدارها",
"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_message": "{lightred}{0}{default} رأی‌گیری برای {lightred}{1}{default} را شروع کرد",
"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_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_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_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}",

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_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_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_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}",

View file

@ -122,6 +122,9 @@
"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_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_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}",
"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_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_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_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}",

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_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_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_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}",

View file

@ -122,6 +122,9 @@
"sa_admin_team_message": "{lightred}{0}{default} перевел {lightred}{1}{default} в команду {lightred}{2}{default}!",
"sa_admin_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ",
"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_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}",
"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_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_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_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı",
"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_warns_menu_title": "{gold}{0} {lime}警告",
"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_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票",
"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
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.
- Staff chat shows the sender's rank, from their highest `#rank/...` group (O, SA, A, TA, M, TM,
H for helper, G for guardian): `@message` in team chat and `css_asay` read
- Staff chat shows the sender's rank, from their highest `#rank/...` group (O, S.Admin, Admin,
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
`@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
@ -29,6 +29,34 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code:
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`).
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"
OWNER="cs2"
REPO="SimpleAdmin"
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their
# repos, mode 600). Don't commit a real token.
TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token"
if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then
FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
fi
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
TAG="${1:-}"
@ -32,7 +37,7 @@ if [[ -z "$TAG" ]]; then
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2
exit 1
fi
for tool in podman jq curl git; do