From 530f60366d7f18ce4dfaf404677874d6c15247c9 Mon Sep 17 00:00:00 2001 From: Ravid-A Date: Sat, 3 Oct 2026 02:18:43 +0300 Subject: [PATCH] Fix unban delay, admin loading, rename persistence; add css_history (1.9.0) Fixes for issues reported by server owners, plus one new command. Unbans took up to a minute to apply, and on SQLite never applied at all: - css_unban only wrote to the database, but the connect check reads an in-memory cache that syncs on a 61s timer. BanManager.UnbanPlayer now marks the bans UNBANNED in CacheManager and rebuilds the indexes, and logs failures instead of swallowing them. - SQLite has no ON UPDATE CURRENT_TIMESTAMP and the unban/expire queries never set updated_at, so the delta refresh never saw the change and the ban stayed active until a plugin reload. Both queries set it now. - The delta query uses >= so a change written in the same second as the refresh snapshot is not skipped. - css_unban issues removeid when UnlockedCommands is on, clearing the engine-level banid that css_ban adds. - RebuildIndexes builds fresh dictionaries and swaps them under a lock, so the game thread cannot read a half-built index now that unbans can trigger a rebuild off the timer thread. Admin permissions were intermittently missing after a restart: - The startup reload fired at a fixed 3s, usually before ServerManager had resolved ServerId, so the admin query ran with a null server and returned only global admins, with nothing reloading afterwards. ServerManager now reloads admins once ServerId is set, and on both of its failure paths so global admins still load. - Loading the plugin mid-map (css_plugins load) fires no map start, so server loading is kicked off after 5s if it has not happened. - CreateAdminsJsonFile no longer strips permissions from inside the ToDictionary lambda, where it ran once per name group and seconds before the new data was loaded. It returns the new cache and PermissionManager.ApplyAdminCache applies it on the main thread immediately before AdminManager.LoadAdminData. - Reloads are serialized with a semaphore and carry a generation, so overlapping ones cannot race on the JSON files or apply a stale snapshot after the hibernation cap. - GetAllPlayersFlags and GetAllGroupsData rethrow instead of returning an empty list; ReloadAdmins catches, logs and aborts. A transient database error previously wrote an empty admins.json and stripped every admin on the server. Permanent renames did not survive a restart: - New sa_renames table (migration 017); css_prename upserts or deletes, and the table is loaded into memory once the server is ready. - The rename is re-applied on connect before the DatabaseProvider / CacheManager early return, which previously skipped it whenever the DB or cache was not ready yet. - New EventPlayerChangename handler, and both it and OnPlayerInfo re-check after a short delay instead of comparing names at event time; the engine may not have written the client's new name yet, and Rename itself takes 0.4s to settle. Smaller fixes: - css_say printed "ADMIN: " with no name. The caller name is passed to the localizer and sa_adminsay_prefix carries {1} in all 13 languages. - css_penalties with @css/kick reassigned only the local user id while every lookup kept using caller.SteamID, so an admin checking another player always saw their own penalties. It now uses the target's SteamID, and replies instead of throwing when that player's connect data has not finished loading. - css_rename returned early for an admin in silent mode, which skipped the rename itself rather than just the announcement. New: css_history <#userid|name|steamid64> [bans|gags|mutes|silences|warns] (@css/kick) prints every ban, gag, mute, silence and warn with status, duration, the admin who issued it, the reason and, for lifted bans and mutes, who lifted it, when and why. A SteamID64 works for players who are not online. Also available in the admin menu under Players > History. The data was always there: unban, unmute and expiry only flip the status column, rows are never deleted. Build: - The csproj listed all 32 migration files by hand, so a new migration silently never reached the output. Replaced with a glob. - Commands missing from an existing Commands.json are registered under their default name, so new commands appear without deleting the file. An empty alias list still disables a command. --- .gitignore | 1 + .../docs/user/commands/basecommands.md | 30 +++ .../docs/user/commands/playercommands.md | 6 +- CS2-SimpleAdmin/CS2-SimpleAdmin.cs | 9 +- CS2-SimpleAdmin/CS2-SimpleAdmin.csproj | 98 +-------- CS2-SimpleAdmin/Commands/RegisterCommands.cs | 9 + CS2-SimpleAdmin/Commands/basechat.cs | 5 +- CS2-SimpleAdmin/Commands/basecommands.cs | 200 +++++++++++++++--- 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 | 33 ++- CS2-SimpleAdmin/Helper.cs | 43 ++-- CS2-SimpleAdmin/Managers/BanManager.cs | 29 ++- CS2-SimpleAdmin/Managers/CacheManager.cs | 56 ++++- CS2-SimpleAdmin/Managers/PermissionManager.cs | 80 ++++--- CS2-SimpleAdmin/Managers/PlayerManager.cs | 92 +++++++- CS2-SimpleAdmin/Managers/ServerManager.cs | 11 +- CS2-SimpleAdmin/Menus/BasicMenu.cs | 57 +++++ CS2-SimpleAdmin/VERSION | 2 +- CS2-SimpleAdmin/Variables.cs | 2 +- CS2-SimpleAdmin/admin_help.txt | 1 + CS2-SimpleAdmin/lang/ar.json | 5 +- CS2-SimpleAdmin/lang/de.json | 5 +- CS2-SimpleAdmin/lang/en.json | 5 +- CS2-SimpleAdmin/lang/es.json | 5 +- CS2-SimpleAdmin/lang/fa.json | 5 +- CS2-SimpleAdmin/lang/fr.json | 5 +- CS2-SimpleAdmin/lang/lv.json | 5 +- CS2-SimpleAdmin/lang/pl.json | 5 +- CS2-SimpleAdmin/lang/pt-BR.json | 5 +- CS2-SimpleAdmin/lang/pt-PT.json | 5 +- CS2-SimpleAdmin/lang/ru.json | 5 +- CS2-SimpleAdmin/lang/tr.json | 5 +- CS2-SimpleAdmin/lang/zh-Hans.json | 5 +- 38 files changed, 694 insertions(+), 247 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 1850b2f5..423b90f5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ CS2-SimpleAdmin_BanSoundModule — kopia CLAUDE.md /Modules/CS2-SimpleAdmin_BanSoundModule /Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN +.claude/ diff --git a/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md b/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md index 5414380f..b56f80f4 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 1db3d932..e51c9599 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 a2a82ee6..7c939247 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"; + public override string ModuleVersion => "1.9.0"; public override void Load(bool hotReload) { @@ -83,7 +83,12 @@ public override void OnAllPluginsLoaded(bool hotReload) RegisterEvents(); AddTimer(0.5f, RegisterCommands.InitializeCommands); - AddTimer(3.0f, () => ReloadAdmins(null)); + // Admins are reloaded by ServerManager once ServerId is known (or on its failure paths). + // Loading the plugin mid-map (css_plugins load) fires no map start, so kick server loading off ourselves. + AddTimer(5.0f, () => + { + if (!ServerLoaded) OnGameServerSteamAPIActivated(); + }); if (!CoreConfig.UnlockConCommands) { diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj index 4284ae6d..58e9e9e6 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 584f3ecf..2edfa434 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 @@ private static void CreateConfig() { "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 @@ private static void Register() 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/basechat.cs b/CS2-SimpleAdmin/Commands/basechat.cs index 9b0662e4..38c6b0dc 100644 --- a/CS2-SimpleAdmin/Commands/basechat.cs +++ b/CS2-SimpleAdmin/Commands/basechat.cs @@ -71,13 +71,16 @@ public void OnAdminSayCommand(CCSPlayerController? caller, CommandInfo command) var utf8BytesString = Encoding.UTF8.GetBytes(command.GetCommandString[command.GetCommandString.IndexOf(' ')..]); var utf8String = Encoding.UTF8.GetString(utf8BytesString); + var callerName = caller?.PlayerName; Helper.LogCommand(caller, command); foreach (var player in Helper.GetValidPlayers()) { + // The message always goes out (it is a broadcast, not an activity notice), + // but the admin's name follows ShowActivityType like every other activity message. player.SendLocalizedMessage(_localizer, "sa_adminsay_prefix", - utf8String.ReplaceColorTags()); + utf8String.ReplaceColorTags(), Helper.ResolveActivityName(player, callerName)); } } diff --git a/CS2-SimpleAdmin/Commands/basecommands.cs b/CS2-SimpleAdmin/Commands/basecommands.cs index 9513cbca..54e31d5c 100644 --- a/CS2-SimpleAdmin/Commands/basecommands.cs +++ b/CS2-SimpleAdmin/Commands/basecommands.cs @@ -36,24 +36,34 @@ public void OnPenaltiesCommand(CCSPlayerController? caller, CommandInfo command) if (caller == null || !caller.IsValid || !caller.UserId.HasValue || DatabaseProvider == null) return; - var userId = caller.UserId.Value; var steamId = caller.SteamID; - + if (!string.IsNullOrEmpty(command.GetArg(1)) && AdminManager.PlayerHasPermissions(new SteamID(caller.SteamID), "@css/kick")) { var targets = GetTarget(command); - + if (targets == null) return; - + var playersToTarget = targets.Players.Where(player => player is { IsValid: true, IsHLTV: false }).ToList(); - playersToTarget.ForEach(player => + if (playersToTarget.Count != 1) { - if (!player.UserId.HasValue) return; - if (!caller.CanTarget(player)) return; + command.ReplyToCommand("Only one target is allowed."); + return; + } - userId = player.UserId.Value; - }); + var target = playersToTarget[0]; + if (!target.UserId.HasValue || !caller.CanTarget(target)) return; + + // Only the user id was reassigned before, so every lookup below used the caller's own penalties + steamId = target.SteamID; + } + + // PlayersInfo is filled by the async connect load; a target who joined a moment ago may not be in it yet + if (!PlayersInfo.ContainsKey(steamId)) + { + command.ReplyToCommand("Player data is still loading, try again in a moment."); + return; } Task.Run(async () => @@ -495,33 +505,74 @@ public void OnRelBans(CCSPlayerController? caller, CommandInfo command) /// Reloads admin data asynchronously and updates admin caches. /// /// The player issuing the reload command. + private static readonly SemaphoreSlim ReloadAdminsLock = new(1, 1); + private static int _reloadGeneration; + public void ReloadAdmins(CCSPlayerController? caller) { if (DatabaseProvider == null) return; - + Task.Run(async () => { - await PermissionManager.CreateGroupsJsonFile(); - await PermissionManager.CreateAdminsJsonFile(); - - var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); - var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json"); - - await Server.NextWorldUpdateAsync(() => + // Serialize reloads end to end: overlapping ones (css_admins_reload, add admin, map change) race on the JSON files, + // and CSS reads them on timers up to 4s later. Capped so a hibernating server cannot hold the lock forever. + await ReloadAdminsLock.WaitAsync(); + // If the cap expires (hibernation) and a newer reload runs, this one's pending timers must not apply a stale snapshot + var generation = Interlocked.Increment(ref _reloadGeneration); + try { - AddTimer(1, () => + await PermissionManager.CreateGroupsJsonFile(); + var admins = await PermissionManager.CreateAdminsJsonFile(); + + var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); + var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json"); + + var loaded = new TaskCompletionSource(); + var scheduled = Server.NextWorldUpdateAsync(() => { - 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")); - - _logger?.LogInformation("Loaded admins!"); + AddTimer(1, () => + { + if (!string.IsNullOrEmpty(adminsFile)) + AddTimer(2.0f, () => + { + if (generation != _reloadGeneration) return; + // Strip old permissions right before loading the new ones, not seconds earlier + PermissionManager.ApplyAdminCache(admins); + AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"); + }); + if (!string.IsNullOrEmpty(groupsFile)) + AddTimer(3.0f, () => + { + if (generation != _reloadGeneration) return; + AdminManager.LoadAdminGroups(ModuleDirectory + "/data/groups.json"); + }); + AddTimer(4.0f, () => + { + if (generation == _reloadGeneration) + { + if (!string.IsNullOrEmpty(adminsFile)) + AdminManager.LoadAdminData(ModuleDirectory + "/data/admins.json"); + _logger?.LogInformation("Loaded admins!"); + } + + // Always completes, so a superseded reload cannot hold the lock until the cap + loaded.TrySetResult(); + }); + }); }); - }); - }); + + await Task.WhenAny(Task.WhenAll(scheduled, loaded.Task), Task.Delay(TimeSpan.FromSeconds(15))); + } + catch (Exception ex) + { + // A failed DB read aborts here, leaving current permissions untouched + _logger?.LogError("Unable to reload admins: {exception}", ex.Message); + } + finally + { + ReloadAdminsLock.Release(); + } + }); //_ = _adminManager.GiveAllGroupsFlags(); //_ = _adminManager.GiveAllFlags(); @@ -836,6 +887,99 @@ await Server.NextWorldUpdateAsync(() => }); } + /// + /// 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 2330ccf5..209f1224 100644 --- a/CS2-SimpleAdmin/Commands/playercommands.cs +++ b/CS2-SimpleAdmin/Commands/playercommands.cs @@ -292,10 +292,9 @@ public void OnRenameCommand(CCSPlayerController? caller, CommandInfo command) 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 void OnPrenameCommand(CCSPlayerController? caller, CommandInfo command) } // 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 e2a4ae45..cdfea209 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 00000000..634bd073 --- /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 00000000..ec893bec --- /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 fa9a27e4..baf4cac5 100644 --- a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs @@ -217,8 +217,8 @@ INSERT INTO `sa_bans` 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 string GetUpdateBanStatusQuery() 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 string GetExpireWarnsQuery(bool 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 + """; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs index d6186fb2..53b21910 100644 --- a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs @@ -143,8 +143,8 @@ INSERT INTO sa_bans 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 string GetInsertUnbanQuery(bool includeReason) => ? "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 string GetExpireIpBansQuery(bool multiServer) => 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 string GetExpireWarnsQuery(bool multiServer) => multiServer ? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime" : "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime 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 8d0149a7..e627d442 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -513,18 +513,31 @@ public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) [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; + EnforceRename(@event.Userid); + return HookResult.Continue; + } - if (player.PlayerName.Equals(name)) - return HookResult.Continue; + [GameEventHandler] + public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _) + { + EnforceRename(@event.Userid); + return HookResult.Continue; + } - player.Rename(name); + /// + /// 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; - return HookResult.Continue; + AddTimer(0.5f, () => + { + if (player.IsValid && !player.PlayerName.Equals(name)) + player.Rename(name); + }); } } \ No newline at end of file diff --git a/CS2-SimpleAdmin/Helper.cs b/CS2-SimpleAdmin/Helper.cs index aff807d6..6d253578 100644 --- a/CS2-SimpleAdmin/Helper.cs +++ b/CS2-SimpleAdmin/Helper.cs @@ -440,6 +440,25 @@ private static void SendDiscordLogMessage(CCSPlayerController? caller, string co _ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command])); } + /// + /// Resolves the admin name one recipient should see, honoring ShowActivityType: + /// on type 0 and 1 only admins see the real name, everyone else sees the generic ADMIN label. + /// The generic and console labels are uppercased so they read as labels beside real player names. + /// + /// The player the message is being sent to. + /// Name of the admin who ran the command, or null for console. + internal static string ResolveActivityName(CCSPlayerController recipient, string? callerName) + { + var hideFromPlayers = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType is 0 or 1; + + if (hideFromPlayers + && !AdminManager.PlayerHasPermissions(new SteamID(recipient.SteamID), "@css/kick") + && !AdminManager.PlayerHasPermissions(new SteamID(recipient.SteamID), "@css/ban")) + return (CS2_SimpleAdmin._localizer?["sa_admin"] ?? "Admin").ToString().ToUpperInvariant(); + + return callerName ?? (CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console").ToString().ToUpperInvariant(); + } + public static void ShowAdminActivity(string messageKey, string? callerName = null, bool dontPublish = false, params object[] messageArgs) { string[] publishActions = ["ban", "gag", "silence", "mute"]; @@ -486,15 +505,9 @@ public static void ShowAdminActivity(string messageKey, string? callerName = nul var currentMessageArgs = (string[])formattedMessageArgs.Clone(); // Replace "CALLER" placeholder based on showActivityType and whether the recipient is an admin + var shownName = ResolveActivityName(controller, callerName); for (var i = 0; i < currentMessageArgs.Length; i++) - { - var arg = currentMessageArgs[i]; - currentMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch - { - 1 => arg.Replace("CALLER", AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/kick") || AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/ban") ? callerName : CS2_SimpleAdmin._localizer["sa_admin"]), - _ => arg.Replace("CALLER", callerName ?? CS2_SimpleAdmin._localizer["sa_console"]), - }; - } + currentMessageArgs[i] = currentMessageArgs[i].Replace("CALLER", shownName); // Send the localized message to each player controller.SendLocalizedMessage(CS2_SimpleAdmin._localizer, messageKey, currentMessageArgs.Cast().ToArray()); @@ -569,15 +582,9 @@ public static void ShowAdminActivityLocalized(IStringLocalizer moduleLocalizer, var currentMessageArgs = (string[])formattedMessageArgs.Clone(); // Replace "CALLER" placeholder based on showActivityType + var shownName = ResolveActivityName(controller, callerName); for (var i = 0; i < currentMessageArgs.Length; i++) - { - var arg = currentMessageArgs[i]; - currentMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch - { - 1 => arg.Replace("CALLER", AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/kick") || AdminManager.PlayerHasPermissions(new SteamID(controller.SteamID), "@css/ban") ? callerName : CS2_SimpleAdmin._localizer["sa_admin"]), - _ => arg.Replace("CALLER", callerName ?? CS2_SimpleAdmin._localizer["sa_console"]), - }; - } + currentMessageArgs[i] = currentMessageArgs[i].Replace("CALLER", shownName); // Send the localized message to each player using their language controller.SendLocalizedMessage(moduleLocalizer, messageKey, currentMessageArgs.Cast().ToArray()); @@ -1011,10 +1018,6 @@ private static void ShowAd(string moduleVersion) Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|"); Console.WriteLine(" >> Version: " + moduleVersion); Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin"); - var _m1 = new List { 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 { 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(" "); } } diff --git a/CS2-SimpleAdmin/Managers/BanManager.cs b/CS2-SimpleAdmin/Managers/BanManager.cs index 5863b593..f8a04b27 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 d02e0367..56daa8df 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 @@ public async Task RefreshCacheAsync() 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 @@ status AS Status 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 @@ status AS Status .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 @@ status AS Status /// 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 @@ private void RebuildIndexes() 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 @@ private void RebuildIndexes() 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/PermissionManager.cs b/CS2-SimpleAdmin/Managers/PermissionManager.cs index 5826e74f..219a804a 100644 --- a/CS2-SimpleAdmin/Managers/PermissionManager.cs +++ b/CS2-SimpleAdmin/Managers/PermissionManager.cs @@ -68,7 +68,7 @@ string s when int.TryParse(s, out var parsed) => parsed, catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError("Unable to load admins from database! {exception}", ex.Message); - return []; + throw; // an empty list would strip every admin; abort the reload and keep the current permissions } } @@ -111,9 +111,8 @@ string s when int.TryParse(s, out var parsed) => parsed, catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError("Unable to load groups from database! {exception}", ex.Message); + throw; } - - return []; } /// @@ -151,7 +150,7 @@ public async Task CreateGroupsJsonFile() /// Creates a JSON file containing admins data asynchronously. /// [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - public async Task CreateAdminsJsonFile() + public async Task flags)>> CreateAdminsJsonFile() { List<(ulong identity, string name, List flags, int immunity, DateTime? ends)> allPlayers = await GetAllPlayersFlags(); var validPlayers = allPlayers @@ -190,47 +189,11 @@ public async Task CreateAdminsJsonFile() return acc; }); - Server.NextWorldUpdate(() => - { - var keysToRemove = new List(); - - 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 newCache = validPlayers.Select(player => (new SteamID(player.identity), player.ends, player.flags)).ToList(); + var options = new JsonSerializerOptions { WriteIndented = true, @@ -240,6 +203,39 @@ public async Task CreateAdminsJsonFile() var json = JsonSerializer.Serialize(jsonData, options); var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json"); await File.WriteAllTextAsync(filePath, json); + + return newCache; + } + + /// + /// Removes permissions of all previously cached admins and replaces the cache with the given admins. + /// Must run on the main thread, right before AdminManager.LoadAdminData so admins are not left stripped. + /// + /// Admins loaded from the database. + public static void ApplyAdminCache(List<(SteamID steamId, DateTime? ends, List flags)> admins) + { + foreach (var steamId in AdminCache.Keys.ToList()) + { + if (!AdminCache.TryRemove(steamId, out var cached)) continue; + + var data = AdminManager.GetPlayerAdminData(steamId); + if (data == null) continue; + + var flagsArray = cached.Flags.ToArray(); + AdminManager.RemovePlayerPermissions(steamId, flagsArray); + AdminManager.RemovePlayerFromGroup(steamId, true, flagsArray); + + data = AdminManager.GetPlayerAdminData(steamId); + if (data == null || (data.Flags.Count != 0 && data.Groups.Count != 0)) continue; + + AdminManager.ClearPlayerPermissions(steamId); + AdminManager.RemovePlayerAdminData(steamId); + } + + foreach (var (steamId, ends, flags) in admins) + { + AdminCache.TryAdd(steamId, (ends, flags)); + } } /// diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index 17361128..d6322a08 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; @@ -44,6 +45,11 @@ public void LoadPlayerData(CCSPlayerController player, bool fullConnect = false) : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; var ipAddress = player.IpAddress?.Split(":")[0]; + 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 () => @@ -219,10 +225,92 @@ await Server.NextWorldUpdateAsync(() => _loadPlayerSemaphore.Release(); } }); + } + + /// + /// 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 + { + 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; - if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name)) + 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) { - player.Rename(name); + 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 ec80aaa0..bda4e5a1 100644 --- a/CS2-SimpleAdmin/Managers/ServerManager.cs +++ b/CS2-SimpleAdmin/Managers/ServerManager.cs @@ -43,6 +43,8 @@ public void LoadServerData() if (_getIpTryCount > 32 && isInvalidIp) { CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!"); + CS2_SimpleAdmin.Instance.ReloadAdmins(null); // still load global admins + _ = CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers(); return; } @@ -103,18 +105,25 @@ await connection.ExecuteAsync( 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) { await CS2_SimpleAdmin.Instance.CacheManager.InitializeCacheAsync(); } + + // Admins are loaded only now, once ServerId is known, so server-specific admins are included + CS2_SimpleAdmin.Instance.ReloadAdmins(null); } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message); + CS2_SimpleAdmin.Instance.ReloadAdmins(null); // still load global admins } + // 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 12340795..8bf6e9a3 100644 --- a/CS2-SimpleAdmin/Menus/BasicMenu.cs +++ b/CS2-SimpleAdmin/Menus/BasicMenu.cs @@ -29,6 +29,7 @@ public static void Initialize() manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat"); manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat"); manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick"); + manager.RegisterMenu("players", "history", "sa_history", CreateHistoryMenu, "@css/kick"); // Server category menus - using translation keys manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root"); @@ -127,6 +128,62 @@ private static MenuBuilder CreateSlayMenu(CCSPlayerController admin) 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/VERSION b/CS2-SimpleAdmin/VERSION index c7a73abd..abb16582 100644 --- a/CS2-SimpleAdmin/VERSION +++ b/CS2-SimpleAdmin/VERSION @@ -1 +1 @@ -1.8.2b \ No newline at end of file +1.9.0 \ No newline at end of file diff --git a/CS2-SimpleAdmin/Variables.cs b/CS2-SimpleAdmin/Variables.cs index 35424a97..2be8b555 100644 --- a/CS2-SimpleAdmin/Variables.cs +++ b/CS2-SimpleAdmin/Variables.cs @@ -75,7 +75,7 @@ private static MemoryFunctionVoid - 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 45335109..be0bee9c 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}لقد ألغيت كتم {default}جميع اللاعبين", "sa_admin_voice_listen_all": "{Default}أنت الآن تسمع {lime}جميع {default}اللاعبين", "sa_admin_voice_unlisten_all": "{Default}أنت الآن لا تسمع {lime}جميع {default}اللاعبين", - "sa_adminsay_prefix": "{RED}الإداري: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(إداري) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(لاعب) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** أصدر الأمر `{1}` على الخادم `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/de.json b/CS2-SimpleAdmin/lang/de.json index f32db527..c49b7211 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Du hast {default}alle Spieler wieder aktiviert", "sa_admin_voice_listen_all": "{Default}Du hörst jetzt {lime}alle {default}Spieler", "sa_admin_voice_unlisten_all": "{Default}Du hörst jetzt {lime}nicht mehr {default}alle Spieler", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(SPIELER) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** hat den Befehl `{1}` auf dem Server `HOSTNAME` ausgeführt", diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index f875b827..e23ea6c0 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} transfered {lightred}{1}{default} to {lightred}{2}{default}!", "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_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}", @@ -130,7 +133,7 @@ "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_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(PLAYER) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** issued command `{1}` on server `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/es.json b/CS2-SimpleAdmin/lang/es.json index e5b0edd3..7b06ccdf 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Has reactivado el sonido de {default}todos los jugadores", "sa_admin_voice_listen_all": "{Default}Ahora puedes escuchar a {lime}todos {default}los jugadores", "sa_admin_voice_unlisten_all": "{Default}Ya no escuchas a {lime}todos {default}los jugadores", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(JUGADOR) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** ejecutó el comando `{1}` en el servidor `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/fa.json b/CS2-SimpleAdmin/lang/fa.json index 77d8273e..48baa99a 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}شما {default}همه بازیکنان را از حالت بی‌صدا خارج کردید", "sa_admin_voice_listen_all": "{Default}اکنون می‌توانید {lime}همه {default}بازیکنان را بشنوید", "sa_admin_voice_unlisten_all": "{Default}شما دیگر {lime}همه {default}بازیکنان را نمی‌شنوید", - "sa_adminsay_prefix": "{RED}ادمین: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ادمین) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(بازیکن) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** فرمان `{1}` را در سرور `HOSTNAME` اجرا کرد", diff --git a/CS2-SimpleAdmin/lang/fr.json b/CS2-SimpleAdmin/lang/fr.json index 27a47319..84597449 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Vous avez réactivé le son de {default}tous les joueurs", "sa_admin_voice_listen_all": "{Default}Vous entendez maintenant {lime}tous {default}les joueurs", "sa_admin_voice_unlisten_all": "{Default}Vous n'entendez plus {lime}tous {default}les joueurs", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(JOUEUR) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** a exécuté la commande `{1}` sur le serveur `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/lv.json b/CS2-SimpleAdmin/lang/lv.json index e27aed82..c7d65c1d 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Tu atvienoji {default}visus spēlētājus", "sa_admin_voice_listen_all": "{Default}Tu tagad dzirdi {lime}visus {default}spēlētājus", "sa_admin_voice_unlisten_all": "{Default}Tu vairs nedzirdi {lime}visus {default}spēlētājus", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(SPĒLĒTĀJS) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** izpildīja komandu `{1}` serverī `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/pl.json b/CS2-SimpleAdmin/lang/pl.json index 3b6b3a7d..e77679f2 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Odciszyłeś {default}wszystkich graczy", "sa_admin_voice_listen_all": "{Default}Słyszysz teraz {lime}wszystkich {default}graczy", "sa_admin_voice_unlisten_all": "{Default}Nie słyszysz teraz {lime}wszystkich {default}graczy", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(GRACZ) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** użył komendy `{1}` na serwerze `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/pt-BR.json b/CS2-SimpleAdmin/lang/pt-BR.json index 299ebc2b..a8785564 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Você reativou o som de {default}todos os jogadores", "sa_admin_voice_listen_all": "{Default}Agora você pode ouvir {lime}todos {default}os jogadores", "sa_admin_voice_unlisten_all": "{Default}Você não ouve mais {lime}todos {default}os jogadores", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** executou o comando `{1}` no servidor `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/pt-PT.json b/CS2-SimpleAdmin/lang/pt-PT.json index 94cce8c5..3bd6ec1f 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Ativaste novamente o som de {default}todos os jogadores", "sa_admin_voice_listen_all": "{Default}Agora consegues ouvir {lime}todos {default}os jogadores", "sa_admin_voice_unlisten_all": "{Default}Já não ouves {lime}todos {default}os jogadores", - "sa_adminsay_prefix": "{RED}ADMIN: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(ADMIN) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(JOGADOR) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** executou o comando `{1}` no servidor `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/ru.json b/CS2-SimpleAdmin/lang/ru.json index f92fb5a4..c7b10cfd 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Вы включили звук {default}всем игрокам", "sa_admin_voice_listen_all": "{Default}Теперь вы слышите {lime}всех {default}игроков", "sa_admin_voice_unlisten_all": "{Default}Вы больше не слышите {lime}всех {default}игроков", - "sa_adminsay_prefix": "{RED}АДМИН: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(АДМИН) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(ИГРОК) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** выполнил команду `{1}` на сервере `HOSTNAME`", diff --git a/CS2-SimpleAdmin/lang/tr.json b/CS2-SimpleAdmin/lang/tr.json index c5203976..b1ca4f58 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}", @@ -130,7 +133,7 @@ "sa_admin_voice_unmute_all": "{Lime}Tüm oyuncuların sesini {default}açtın", "sa_admin_voice_listen_all": "{Default}Artık {lime}tüm {default}oyuncuları duyabiliyorsun", "sa_admin_voice_unlisten_all": "{Default}Artık {lime}tüm {default}oyuncuları duymuyorsun", - "sa_adminsay_prefix": "{RED}Yönetici: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(Yönetici) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(Oyuncu) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** `{1}` komutunu `HOSTNAME` sunucusunda gerçekleştirdi", diff --git a/CS2-SimpleAdmin/lang/zh-Hans.json b/CS2-SimpleAdmin/lang/zh-Hans.json index c3d2331c..ae256861 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}", @@ -128,7 +131,7 @@ "sa_admin_voice_unmute_all": "{Lime}你已取消了{default}所有玩家的静音", "sa_admin_voice_listen_all": "{Default}你现在可以听到{lime}所有{default}玩家了", "sa_admin_voice_unlisten_all": "{Default}你现在不再听到{lime}所有{default}玩家了", - "sa_adminsay_prefix": "{RED}管理员: {lightred}{0}{default}", + "sa_adminsay_prefix": "{RED}{1}: {lightred}{0}{default}", "sa_adminchat_template_admin": "{LIME}(管理员) {lightred}{0}{default}: {lightred}{1}{default}", "sa_adminchat_template_player": "{SILVER}(玩家) {lightred}{0}{default}: {lightred}{1}{default}", "sa_discord_log_command": "**{0}** 在服务器 `HOSTNAME` 上执行了命令 `{1}`",