From 71d51d7b92858b32229a367f7286e55b13236b2e Mon Sep 17 00:00:00 2001 From: Astra Date: Sat, 3 Oct 2026 12:08:45 +0100 Subject: [PATCH] zio fork 1.8.2b-zio16: backport unban, rename and css_history from upstream 1.9.0 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. --- .gitignore | 1 + .../docs/user/commands/basecommands.md | 30 ++++++ .../docs/user/commands/playercommands.md | 6 +- CS2-SimpleAdmin/CS2-SimpleAdmin.cs | 2 +- CS2-SimpleAdmin/CS2-SimpleAdmin.csproj | 98 +------------------ CS2-SimpleAdmin/Commands/RegisterCommands.cs | 9 ++ CS2-SimpleAdmin/Commands/basecommands.cs | 93 ++++++++++++++++++ CS2-SimpleAdmin/Commands/playercommands.cs | 12 ++- CS2-SimpleAdmin/Database/IDatabaseProvider.cs | 10 +- .../Mysql/017_CreateRenamesTable.sql | 5 + .../Sqlite/017_CreateRenamesTable.sql | 4 + .../Database/MysqlDatabaseProvider.cs | 36 ++++++- .../Database/SqliteDatabaseProvider.cs | 45 +++++++-- CS2-SimpleAdmin/Events.cs | 37 ++++--- CS2-SimpleAdmin/Managers/BanManager.cs | 29 +++++- CS2-SimpleAdmin/Managers/CacheManager.cs | 56 ++++++++--- CS2-SimpleAdmin/Managers/PlayerManager.cs | 92 ++++++++++++++++- CS2-SimpleAdmin/Managers/ServerManager.cs | 6 +- CS2-SimpleAdmin/Menus/BasicMenu.cs | 57 +++++++++++ CS2-SimpleAdmin/Variables.cs | 2 +- CS2-SimpleAdmin/admin_help.txt | 1 + CS2-SimpleAdmin/lang/ar.json | 3 + CS2-SimpleAdmin/lang/de.json | 3 + CS2-SimpleAdmin/lang/en.json | 3 + CS2-SimpleAdmin/lang/es.json | 3 + CS2-SimpleAdmin/lang/fa.json | 3 + CS2-SimpleAdmin/lang/fr.json | 3 + CS2-SimpleAdmin/lang/lv.json | 3 + CS2-SimpleAdmin/lang/pl.json | 3 + CS2-SimpleAdmin/lang/pt-BR.json | 3 + CS2-SimpleAdmin/lang/pt-PT.json | 3 + CS2-SimpleAdmin/lang/ru.json | 3 + CS2-SimpleAdmin/lang/tr.json | 3 + CS2-SimpleAdmin/lang/zh-Hans.json | 3 + README.md | 12 +++ 35 files changed, 539 insertions(+), 143 deletions(-) create mode 100644 CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql create mode 100644 CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql diff --git a/.gitignore b/.gitignore index 55358e7..dad4536 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ CLAUDE.md /Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN release-out/ +.claude/ diff --git a/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md b/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md index 5414380..b56f80f 100644 --- a/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md +++ b/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md @@ -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. diff --git a/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md b/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md index 1db3d93..e51c959 100644 --- a/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md +++ b/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md @@ -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 ` with no name removes the rename - Useful for offensive names --- diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index c4ea959..338a41f 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig "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-zio15"; + public override string ModuleVersion => "1.8.2b-zio16"; public override void Load(bool hotReload) { diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj index a3ad25d..98ecdcd 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj @@ -36,106 +36,10 @@ - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - + diff --git a/CS2-SimpleAdmin/Commands/RegisterCommands.cs b/CS2-SimpleAdmin/Commands/RegisterCommands.cs index 584f3ec..2edfa43 100644 --- a/CS2-SimpleAdmin/Commands/RegisterCommands.cs +++ b/CS2-SimpleAdmin/Commands/RegisterCommands.cs @@ -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) diff --git a/CS2-SimpleAdmin/Commands/basecommands.cs b/CS2-SimpleAdmin/Commands/basecommands.cs index 4b2302e..e0c362b 100644 --- a/CS2-SimpleAdmin/Commands/basecommands.cs +++ b/CS2-SimpleAdmin/Commands/basecommands.cs @@ -846,6 +846,99 @@ public partial class CS2_SimpleAdmin }); } + /// + /// 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. + /// + /// The player issuing the command or null for console. + /// The command containing the target and an optional penalty type filter. + [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 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); + }); + }); + } + + /// + /// Writes history rows from as one console line each. + /// + internal void PrintHistory(Action print, string name, ulong steamId, List 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") ?? "?"; + } + /// /// Lists players currently connected to the server with options to output JSON or filter duplicate IPs. /// diff --git a/CS2-SimpleAdmin/Commands/playercommands.cs b/CS2-SimpleAdmin/Commands/playercommands.cs index 2330ccf..209f122 100644 --- a/CS2-SimpleAdmin/Commands/playercommands.cs +++ b/CS2-SimpleAdmin/Commands/playercommands.cs @@ -292,10 +292,9 @@ 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; + if (caller != null && !SilentPlayers.Contains(caller.Slot)) + Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs); - 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)); }); } diff --git a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs index e2a4ae4..cdfea20 100644 --- a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs @@ -41,7 +41,12 @@ public interface IDatabaseProvider string GetExpireBansQuery(bool multiServer); 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); } \ No newline at end of file diff --git a/CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql b/CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql new file mode 100644 index 0000000..634bd07 --- /dev/null +++ b/CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql @@ -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; diff --git a/CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql b/CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql new file mode 100644 index 0000000..ec893be --- /dev/null +++ b/CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS `sa_renames` ( + `player_steamid` INTEGER NOT NULL PRIMARY KEY, + `name` TEXT NOT NULL +); diff --git a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs index fa9a27e..baf4cac 100644 --- a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs @@ -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; } diff --git a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs index d6186fb..53b2191 100644 --- a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs @@ -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 @@ -169,7 +170,16 @@ 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 + """; } diff --git a/CS2-SimpleAdmin/Events.cs b/CS2-SimpleAdmin/Events.cs index bd0a404..7559a72 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -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; + } + + /// + /// 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). + /// + 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); + }); + } } \ No newline at end of file diff --git a/CS2-SimpleAdmin/Managers/BanManager.cs b/CS2-SimpleAdmin/Managers/BanManager.cs index 5863b59..f8a04b2 100644 --- a/CS2-SimpleAdmin/Managers/BanManager.cs +++ b/CS2-SimpleAdmin/Managers/BanManager.cs @@ -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) diff --git a/CS2-SimpleAdmin/Managers/CacheManager.cs b/CS2-SimpleAdmin/Managers/CacheManager.cs index db74f2c..165efac 100644 --- a/CS2-SimpleAdmin/Managers/CacheManager.cs +++ b/CS2-SimpleAdmin/Managers/CacheManager.cs @@ -10,8 +10,10 @@ namespace CS2_SimpleAdmin.Managers; internal class CacheManager: IDisposable { private readonly ConcurrentDictionary _banCache = []; - private readonly ConcurrentDictionary> _steamIdIndex = []; - private readonly ConcurrentDictionary> _ipIndex = []; + // Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index + private ConcurrentDictionary> _steamIdIndex = []; + private ConcurrentDictionary> _ipIndex = []; + private readonly object _indexLock = new(); private readonly ConcurrentDictionary> _playerIpsCache = []; private HashSet _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 /// private void RebuildIndexes() { - _steamIdIndex.Clear(); - _ipIndex.Clear(); + // SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here + lock (_indexLock) + { + RebuildIndexesCore(); + } + } + + /// + /// 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. + /// + /// Ban ids to update. + /// New status. + public void SetBanStatus(IEnumerable 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>(); + var ipIndex = new ConcurrentDictionary>(); // 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(); - _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(); - _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); } /// diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index 44debaa..a7422ef 100644 --- a/CS2-SimpleAdmin/Managers/PlayerManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerManager.cs @@ -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)) + /// + /// 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. + /// + /// SteamID64 of the player, online or not. + /// Optional filter: bans, gags, mutes, silences or warns. + public async Task> 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 []; + } + } + + /// + /// Dapper gives DateTime on MySQL but can give a string on SQLite; accept both. + /// + 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 + }; + + /// + /// Loads permanent renames from the database into memory. + /// + 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); + } + } + + /// + /// Persists or removes a permanent rename (css_prename) for a player. + /// + /// SteamID64 of the player. + /// Forced name, or null/empty to remove the rename. + 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); } } diff --git a/CS2-SimpleAdmin/Managers/ServerManager.cs b/CS2-SimpleAdmin/Managers/ServerManager.cs index ec80aaa..10dcc16 100644 --- a/CS2-SimpleAdmin/Managers/ServerManager.cs +++ b/CS2-SimpleAdmin/Managers/ServerManager.cs @@ -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}"; diff --git a/CS2-SimpleAdmin/Menus/BasicMenu.cs b/CS2-SimpleAdmin/Menus/BasicMenu.cs index ae9ef3f..41c8ea1 100644 --- a/CS2-SimpleAdmin/Menus/BasicMenu.cs +++ b/CS2-SimpleAdmin/Menus/BasicMenu.cs @@ -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(); } + /// + /// 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. + /// + /// The admin player opening the menu. + /// A MenuBuilder instance for the history menu. + 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(); + } + /// /// Creates menu for kicking players with reason selection. /// diff --git a/CS2-SimpleAdmin/Variables.cs b/CS2-SimpleAdmin/Variables.cs index d3566d6..ab65b36 100644 --- a/CS2-SimpleAdmin/Variables.cs +++ b/CS2-SimpleAdmin/Variables.cs @@ -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; diff --git a/CS2-SimpleAdmin/admin_help.txt b/CS2-SimpleAdmin/admin_help.txt index e8ce7af..8a01ab5 100644 --- a/CS2-SimpleAdmin/admin_help.txt +++ b/CS2-SimpleAdmin/admin_help.txt @@ -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 [time in minutes/0 perm] [reason] - Ban player via steamid64 diff --git a/CS2-SimpleAdmin/lang/ar.json b/CS2-SimpleAdmin/lang/ar.json index 4533510..1236e7f 100644 --- a/CS2-SimpleAdmin/lang/ar.json +++ b/CS2-SimpleAdmin/lang/ar.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/de.json b/CS2-SimpleAdmin/lang/de.json index f32db52..dfdfc85 100644 --- a/CS2-SimpleAdmin/lang/de.json +++ b/CS2-SimpleAdmin/lang/de.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index 36b31c6..d808e55 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -131,6 +131,9 @@ "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 {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": "{red}{0}{default} started voting for {orange}{1}", "sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {orange}{0}", diff --git a/CS2-SimpleAdmin/lang/es.json b/CS2-SimpleAdmin/lang/es.json index e5b0edd..a966057 100644 --- a/CS2-SimpleAdmin/lang/es.json +++ b/CS2-SimpleAdmin/lang/es.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/fa.json b/CS2-SimpleAdmin/lang/fa.json index 77d8273..a15fbd8 100644 --- a/CS2-SimpleAdmin/lang/fa.json +++ b/CS2-SimpleAdmin/lang/fa.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/fr.json b/CS2-SimpleAdmin/lang/fr.json index 27a4731..391a3a5 100644 --- a/CS2-SimpleAdmin/lang/fr.json +++ b/CS2-SimpleAdmin/lang/fr.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/lv.json b/CS2-SimpleAdmin/lang/lv.json index e27aed8..1128ff3 100644 --- a/CS2-SimpleAdmin/lang/lv.json +++ b/CS2-SimpleAdmin/lang/lv.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/pl.json b/CS2-SimpleAdmin/lang/pl.json index 3b6b3a7..ae6be91 100644 --- a/CS2-SimpleAdmin/lang/pl.json +++ b/CS2-SimpleAdmin/lang/pl.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/pt-BR.json b/CS2-SimpleAdmin/lang/pt-BR.json index 299ebc2..95f08cb 100644 --- a/CS2-SimpleAdmin/lang/pt-BR.json +++ b/CS2-SimpleAdmin/lang/pt-BR.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/pt-PT.json b/CS2-SimpleAdmin/lang/pt-PT.json index 94cce8c..93fc675 100644 --- a/CS2-SimpleAdmin/lang/pt-PT.json +++ b/CS2-SimpleAdmin/lang/pt-PT.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/ru.json b/CS2-SimpleAdmin/lang/ru.json index f92fb5a..de16a5f 100644 --- a/CS2-SimpleAdmin/lang/ru.json +++ b/CS2-SimpleAdmin/lang/ru.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/tr.json b/CS2-SimpleAdmin/lang/tr.json index c520397..12c9cbd 100644 --- a/CS2-SimpleAdmin/lang/tr.json +++ b/CS2-SimpleAdmin/lang/tr.json @@ -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}", diff --git a/CS2-SimpleAdmin/lang/zh-Hans.json b/CS2-SimpleAdmin/lang/zh-Hans.json index c3d2331..d8597da 100644 --- a/CS2-SimpleAdmin/lang/zh-Hans.json +++ b/CS2-SimpleAdmin/lang/zh-Hans.json @@ -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}", diff --git a/README.md b/README.md index b0669d1..47b3a1b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code: `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. +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 is `1.8.2b-zioN`). It rebuilds from the committed source and uploads `SimpleAdmin-.tar.gz` to git.zio.sh (`cs2/SimpleAdmin`), laid out like `game/csgo/`. It holds the whole plugin (its libraries, all lang