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