using System.Globalization; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.ValveConstants.Protobuf; using CS2_SimpleAdmin.Models; using CS2_SimpleAdminApi; using Dapper; using Microsoft.Extensions.Logging; using ZLinq; namespace CS2_SimpleAdmin.Managers; internal class PlayerManager { private readonly SemaphoreSlim _loadPlayerSemaphore = new(6); private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config; /// /// Loads and initializes player data when a client connects. /// /// The instance representing the connecting player. /// /// Determines whether to perform a full synchronization of player data. /// If true, full checks (bans, IP history, penalties, warns, mutes) will be loaded and applied. /// /// /// This method validates the player's identity, checks for bans, updates the IP history table, /// loads penalties (mutes/gags/warns), and optionally notifies admin players about the connecting player's penalties. /// // zio: players or groups with this aren't held back by IP bans, or by bans on other accounts that // share their IP; only a ban on their own SteamID applies. @css/root has it too, as it has every // @css flag. internal const string IpBanBypassPermission = "@css/bypassipban"; // zio: the reason and time left of the ban that matched, which the ban cache doesn't hold. private static async Task<(string? Reason, int Duration, DateTime? Ends)> GetBanDetails(int banId) { try { if (CS2_SimpleAdmin.DatabaseProvider == null) return default; await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); return await connection.QueryFirstOrDefaultAsync<(string? Reason, int Duration, DateTime? Ends)>( "SELECT reason, duration, ends FROM sa_bans WHERE id = @Id", new { Id = banId }); } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError($"Couldn't read ban {banId}: {ex.Message}"); return default; } } // zio: tells a banned player why, in chat and on screen, before they're kicked. private static void TellBanned(CCSPlayerController player, (string? Reason, int Duration, DateTime? Ends) ban, bool otherAccount) { var localizer = CS2_SimpleAdmin._localizer; if (localizer == null) return; var reason = string.IsNullOrWhiteSpace(ban.Reason) ? localizer["sa_player_penalty_no_reason"].Value : ban.Reason; var left = ban.Duration == 0 || ban.Ends == null ? null : Helper.FormatRemaining(ban.Ends.Value - Time.ActualDateTime()); if (left == null) player.SendLocalizedMessage(localizer, "sa_player_ban_active_perm", reason); else player.SendLocalizedMessage(localizer, "sa_player_ban_active", left, reason); if (otherAccount) player.SendLocalizedMessage(localizer, "sa_player_ban_active_other_account"); player.SendLocalizedMessage(localizer, "sa_player_ban_active_kick"); player.PrintToCenter(left == null ? $"Banned permanently: {reason}" : $"Banned for {left}: {reason}"); } public void LoadPlayerData(CCSPlayerController player, bool fullConnect = false) { if (!player.UserId.HasValue) { Helper.KickPlayer(player, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_INVALIDCONNECTION); return; } var userId = player.UserId.Value; var slot = player.Slot; var steamId = player.SteamID; var playerName = !string.IsNullOrEmpty(player.PlayerName) ? player.PlayerName : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; var ipAddress = player.IpAddress?.Split(":")[0]; // zio: read on the main thread; the ban check below runs on a worker thread. var bypassIpBans = AdminManager.PlayerHasPermissions(new SteamID(steamId), IpBanBypassPermission); if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(steamId, out var renamedTo)) { player.Rename(renamedTo); } if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return; Task.Run(async () => { try { await _loadPlayerSemaphore.WaitAsync(); // Save ip address before ban check await SavePlayerIpAddress(steamId, playerName, ipAddress); // Always check bans first, regardless of PlayersInfo state BanRecord? ban; bool isBanned; if (bypassIpBans) { // zio: only a ban on their own SteamID applies, never an IP ban or a ban on another // account that shares their IP. ban = CS2_SimpleAdmin.Instance.CacheManager.GetPlayerBansBySteamId(steamId) .FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); isBanned = ban != null; } else { isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch { 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null, out ban), _ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId, ipAddress, out ban) : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress, out ban) }; } CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Player {playerName} ({steamId}) IP: {ipAddress} - BanType: {CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType} - CheckMultiAccounts: {CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp} - isBanned: {isBanned}"); if (isBanned) { // zio: the disconnect screen can't carry a reason, so let them finish connecting, // tell them why in chat and on screen, then kick after KickTime. if (!fullConnect) return; CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] KICKING {playerName} ({steamId})"); var details = ban == null ? default : await GetBanDetails(ban.Id); var otherAccount = CS2_SimpleAdmin.Instance.CacheManager.GetPlayerBansBySteamId(steamId) .All(b => b.StatusEnum != BanStatus.ACTIVE); await Server.NextWorldUpdateAsync(() => { CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Executing kick for {playerName}"); var banned = Utilities.GetPlayerFromUserid(userId); if (banned is { IsValid: true }) { CS2_SimpleAdmin.PendingBanKicks.Add(banned.Slot); TellBanned(banned, details, otherAccount); } Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED, Math.Max(1, CS2_SimpleAdmin.Instance.Config.OtherSettings.KickTime)); }); return; } if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId)) { var playerInfo = new PlayerInfo(userId, slot, new SteamID(steamId), playerName, ipAddress); CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo; if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null) { playerInfo.AccountsAssociated = CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable() .Select(x => (x.SteamId, x.PlayerName)).ToList() ?? []; } try { // var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 // ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned( // CS2_SimpleAdmin.PlayersInfo[userId].SteamId.SteamId64.ToString(), null) // : CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp // ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(CS2_SimpleAdmin // .PlayersInfo[userId].SteamId.SteamId64) // : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(CS2_SimpleAdmin.PlayersInfo[userId].SteamId.SteamId64.ToString(), ipAddress); if (CS2_SimpleAdmin.PlayersInfo.TryGetValue(steamId, out PlayerInfo? value)) // Temp skip { var warns = await CS2_SimpleAdmin.Instance.WarnManager.GetPlayerWarns(value, false); var (totalMutes, totalGags, totalSilences) = await CS2_SimpleAdmin.Instance.MuteManager.GetPlayerMutes(value); value.TotalBans = CS2_SimpleAdmin.Instance.CacheManager ?.GetPlayerBansBySteamId(value.SteamId.SteamId64) .Count ?? 0; value.TotalMutes = totalMutes; value.TotalGags = totalGags; value.TotalSilences = totalSilences; value.TotalWarns = warns.Count; var activeMutes = await CS2_SimpleAdmin.Instance.MuteManager.IsPlayerMuted(value.SteamId.SteamId64 .ToString()); if (activeMutes.Count > 0) { foreach (var mute in activeMutes) { string muteType = mute.type; DateTime ends = mute.ends; int duration = mute.duration; string? reason = mute.reason; int passed = mute.passed is null ? 0 : (int)mute.passed; switch (muteType) { // Apply mute penalty based on mute type case "GAG": PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, PenaltyType.Gag, ends, duration, reason, duration - passed); // if (CS2_SimpleAdmin._localizer != null) // mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]); break; case "MUTE": PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, PenaltyType.Mute, ends, duration, reason, duration - passed); await Server.NextWorldUpdateAsync(() => { player.VoiceFlags = VoiceFlags.Muted; }); // if (CS2_SimpleAdmin._localizer != null) // mutesList[PenaltyType.Mute].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_mute", ends.ToLocalTime().ToString(CultureInfo.InvariantCulture)]); break; default: PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, PenaltyType.Silence, ends, duration, reason, duration - passed); await Server.NextWorldUpdateAsync(() => { player.VoiceFlags = VoiceFlags.Muted; }); // if (CS2_SimpleAdmin._localizer != null) // mutesList[PenaltyType.Silence].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_silence", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]); break; } } } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.NotifyPenaltiesToAdminOnConnect) { await Server.NextWorldUpdateAsync(() => { foreach (var admin in Helper.GetValidPlayers() .Where(p => (AdminManager.PlayerHasPermissions( new SteamID(p.SteamID), "@css/kick") || AdminManager.PlayerHasPermissions( new SteamID(p.SteamID), "@css/ban")) && p.Connected == PlayerConnectedState.Connected && !CS2_SimpleAdmin.AdminDisabledJoinComms .Contains(p.SteamID))) { if (CS2_SimpleAdmin._localizer == null || admin == player) continue; admin.SendLocalizedMessage(CS2_SimpleAdmin._localizer, "sa_admin_penalty_info", player.PlayerName, CS2_SimpleAdmin.PlayersInfo[steamId].TotalBans, CS2_SimpleAdmin.PlayersInfo[steamId].TotalGags, CS2_SimpleAdmin.PlayersInfo[steamId].TotalMutes, CS2_SimpleAdmin.PlayersInfo[steamId].TotalSilences, CS2_SimpleAdmin.PlayersInfo[steamId].TotalWarns ); if (CS2_SimpleAdmin.PlayersInfo[steamId].AccountsAssociated.Count >= 2) { var associatedAcccountsChunks = CS2_SimpleAdmin.PlayersInfo[steamId].AccountsAssociated.ChunkBy(5) .ToList(); foreach (var chunk in associatedAcccountsChunks) { admin.SendLocalizedMessage(CS2_SimpleAdmin._localizer, "sa_admin_associated_accounts", player.PlayerName, string.Join(", ", chunk.Select(a => $"{a.PlayerName} ({a.SteamId})")) ); } } } }); } } } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError("Error processing player connection: {exception}", ex.Message); } } } finally { _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; try { await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); if (string.IsNullOrEmpty(name)) await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetDeleteRenameQuery(), new { steamId }); else await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetUpsertRenameQuery(), new { steamId, name }); } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError("Unable to save rename: {exception}", ex.Message); } } /// /// Saves player's IP address to the database for multi-account detection. /// This is called before ban checks to ensure IP is recorded even if player is banned. /// private async Task SavePlayerIpAddress(ulong steamId, string playerName, string? ipAddress) { if (!_config.OtherSettings.CheckMultiAccountsByIp || ipAddress == null || CS2_SimpleAdmin.DatabaseProvider == null) return; try { await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); var steamId64 = steamId; var ipUint = IpHelper.IpToUint(ipAddress); var upsertQuery = CS2_SimpleAdmin.DatabaseProvider.GetUpsertPlayerIpQuery(); await connection.ExecuteAsync(upsertQuery, new { SteamID = steamId64, playerName, IPAddress = ipUint }); } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError( $"Unable to save ip address for {playerName} ({ipAddress}): {ex.Message}"); } } /// /// Periodically checks the status of online players and applies timers for speed, gravity, /// and penalty expiration validation. /// /// /// This method registers two repeating timers: /// /// One short-interval timer to update speed/gravity modifications applied to players. /// /// One long-interval timer (default 61 seconds) to expire bans, mutes, warns, refresh caches, /// and remove outdated penalties from connected players. /// /// /// Additionally, banned players still online are kicked, and admins may be updated about mute statuses based on the configured time mode. /// public void CheckPlayersTimer() { CS2_SimpleAdmin.Instance.AddTimer(5f, () => { foreach (var (steamid, name) in CS2_SimpleAdmin.RenamedPlayers) { var player = Helper.GetPlayerFromSteamid64(steamid); if (player == null || !player.IsValid || player.PlayerName == name) continue; player.Rename(name); } }, TimerFlags.REPEAT); CS2_SimpleAdmin.Instance.PlayersTimer = CS2_SimpleAdmin.Instance.AddTimer(61.0f, () => { #if DEBUG CS2_SimpleAdmin._logger?.LogCritical("[OnMapStart] Expired check"); #endif if (CS2_SimpleAdmin.DatabaseProvider == null) return; // Optimization: Get players once and avoid allocating anonymous types var validPlayers = Helper.GetValidPlayers(); // Use ValueTuple instead of anonymous type - better performance and less allocations var tempPlayers = new List<(string PlayerName, ulong SteamID, string? IpAddress, int? UserId, int Slot)>(validPlayers.Count); // zio: players with IpBanBypassPermission, read here on the main thread, only count bans on // their own SteamID. var bypassSteamIds = new HashSet(); foreach (var p in validPlayers) { tempPlayers.Add((p.PlayerName, p.SteamID, p.IpAddress, p.UserId, p.Slot)); if (AdminManager.PlayerHasPermissions(new SteamID(p.SteamID), IpBanBypassPermission)) bypassSteamIds.Add(p.SteamID); } var pluginInstance = CS2_SimpleAdmin.Instance; var config = _config.OtherSettings; // Cache config access _ = Task.Run(async () => { try { // Run all expire tasks in parallel var expireTasks = new[] { pluginInstance.BanManager.ExpireOldBans(), pluginInstance.MuteManager.ExpireOldMutes(), pluginInstance.WarnManager.ExpireOldWarns(), pluginInstance.CacheManager?.RefreshCacheAsync() ?? Task.CompletedTask, pluginInstance.PermissionManager.DeleteOldAdmins() }; await Task.WhenAll(expireTasks); } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError($"Error processing players timer tasks: {ex.Message}"); if (ex is AggregateException aggregate) { foreach (var inner in aggregate.InnerExceptions) { CS2_SimpleAdmin._logger?.LogError($"Inner exception: {inner.Message}"); } } } if (pluginInstance.CacheManager == null) return; // Optimization: Cache ban type and multi-account check to avoid repeated config access var banType = config.BanType; var checkMultiAccounts = config.CheckMultiAccountsByIp; var bannedPlayers = new List<(string PlayerName, ulong SteamID, string? IpAddress, int? UserId, int Slot)>(); // Manual loop instead of LINQ - better performance foreach (var player in tempPlayers) { var playerName = player.PlayerName; var steamId = player.SteamID; var ip = player.IpAddress?.Split(':')[0]; bool isBanned = bypassSteamIds.Contains(steamId) ? pluginInstance.CacheManager.GetPlayerBansBySteamId(steamId).Any(b => b.StatusEnum == BanStatus.ACTIVE) : banType switch { 0 => pluginInstance.CacheManager.IsPlayerBanned(playerName, steamId, null), _ => checkMultiAccounts ? pluginInstance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId, ip) : pluginInstance.CacheManager.IsPlayerBanned(playerName, steamId, ip) }; if (isBanned) { bannedPlayers.Add(player); } } if (bannedPlayers.Count > 0) { foreach (var player in bannedPlayers) { if (!player.UserId.HasValue) continue; await Server.NextWorldUpdateAsync(() => { if (Helper.GetPlayerFromSteamid64(player.SteamID) != null) Helper.KickPlayer((int)player.UserId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); }); } } if (config.TimeMode == 0) { // Optimization: Manual projection instead of LINQ var onlinePlayers = new List<(ulong, int?, int)>(tempPlayers.Count); foreach (var player in tempPlayers) { onlinePlayers.Add((player.SteamID, player.UserId, player.Slot)); } if (onlinePlayers.Count > 0) { await pluginInstance.MuteManager.CheckOnlineModeMutes(onlinePlayers); } } }); try { // Optimization: Process penalties without LINQ allocations var players = Helper.GetValidPlayers(); foreach (var player in players) { if (!PlayerPenaltyManager.IsSlotInPenalties(player.Slot)) continue; var isMuted = PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Mute, out _); var isSilenced = PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out _); // Only reset voice flags if not muted or silenced if (!isMuted && !isSilenced) { player.VoiceFlags = VoiceFlags.Normal; } } PlayerPenaltyManager.RemoveExpiredPenalties(); } catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError($"Unable to remove old penalties: {ex.Message}"); } }, TimerFlags.REPEAT); } }