Compare commits

...

19 Commits

Author SHA1 Message Date
Dawid Bepierszcz
eea700bfb4 Version bump and multi-account/IP ban fixes
Bump version to 1.7.9a and update package references (CounterStrikeSharp.API, Dapper, System.Linq.Async, ZLinq). Replace usages of PlayerConnectedState.PlayerConnected with PlayerConnectedState.Connected across commands, events, and helpers. Add DB method GetExpireOldPlayerIpsQuery (MySQL/SQLite) and call it to purge old player IP records during ban expiration. Improve CacheManager multi-account/ip ban detection (direct IP bans, cross-account IP checks) and adjust ban logic to respect expiration window. In PlayerManager: reduce semaphore, save player IP to DB before performing ban checks (add SavePlayerIpAddress), and always perform ban checks on connect. Simplify config upgrade logic in Helper.UpdateConfig to update the JSON file Version via JsonNode. Misc: adjust ban sound volume, minor exception catch cleanup, and add module folder to .gitignore.
2026-04-23 22:15:44 +02:00
Dawid Bepierszcz
91615ffc67 Update VERSION 2026-01-27 13:50:27 +01:00
Dawid Bepierszcz
eb9b438315 Update CS2-SimpleAdmin.cs 2026-01-27 13:47:41 +01:00
Dawid Bepierszcz
3d23b8981b Update VERSION 2026-01-27 12:21:07 +01:00
Dawid Bepierszcz
39cbfdab1e Update CS2-SimpleAdmin.cs 2026-01-27 12:20:56 +01:00
Dawid Bepierszcz
4599f08fd7 Update basecommands.cs 2026-01-27 12:20:29 +01:00
Dawid Bepierszcz
4c43f14c82 Update VERSION 2026-01-27 09:52:44 +01:00
Dawid Bepierszcz
f16f8cf1a5 Update CS2-SimpleAdmin.cs 2026-01-27 09:52:31 +01:00
Dawid Bepierszcz
193685826c Update ServerManager.cs 2026-01-27 09:51:12 +01:00
Dawid Bepierszcz
fe73fa9917 Refactor admin and server loading timing logic
Adjusted timers for admin and server data loading to improve reliability and reduce delays. Admin data is now loaded more promptly on map changes and during command execution. ServerManager now initializes the cache after setting the server ID, and redundant admin reloads have been removed. Version bumped to 1.7.8-beta-10.
2026-01-26 01:19:25 +01:00
Dawid Bepierszcz
bdada2df1e Update VERSION 2026-01-25 14:41:33 +01:00
Dawid Bepierszcz
310a43fcd9 Update CS2-SimpleAdmin.cs 2026-01-25 14:39:01 +01:00
Dawid Bepierszcz
58243e813a Update StatusBlocker plugin binaries
Replaced StatusBlocker-v1.1.4 binaries for both Linux and Windows in the METAMOD PLUGIN directory. This may include bug fixes or improvements in the updated plugin versions.
2026-01-25 14:36:47 +01:00
Dawid Bepierszcz
d53446e0fe Bump version to 1.7.8-beta-8-recompiled
Updated the ModuleVersion string to reflect a recompiled build. No other changes were made.
2026-01-25 14:10:57 +01:00
Dawid Bepierszcz
2404c1bc03 Update dependencies and StatusBlocker plugin version
Upgraded several NuGet packages in CS2-SimpleAdmin and CS2-SimpleAdminApi projects, including CounterStrikeSharp.API, MySqlConnector, System.Linq.Async, and ZLinq. Replaced StatusBlocker v1.1.3 plugin files with v1.1.4 for both Linux and Windows in the StealthModule.
2026-01-25 14:09:12 +01:00
Dawid Bepierszcz
665962565e Update ban logic and StatusBlocker plugin version
Refactored IP ban checking logic in CacheManager for improved accuracy and maintainability. Replaced StatusBlocker plugin binaries with v1.1.3 for both Linux and Windows.

EOL, no more new features
2025-12-13 16:54:33 +01:00
Dawid Bepierszcz
c2e8b4a898 Refactor player cache and ban checks, update version
Improves player cache handling and ban status checks for race condition safety. Removes unused GodPlayers logic and related event handler. Refactors event handlers for disconnect and team changes, and fixes warn reason field naming. Updates version to 1.7.8-beta-7.
2025-12-02 17:37:14 +01:00
Dawid Bepierszcz
9723a4faee :(
:(
2025-11-13 01:40:50 +01:00
Dawid Bepierszcz
4865b76262 Improve ban cache sync and update dependencies
Enhanced CacheManager to use database time for ban updates, improving multi-server consistency and handling of status changes. Increased PlayerManager's semaphore limit and improved player/bans refresh logic to ensure status changes are detected even when the server is empty. Updated author and version metadata, commented out duplicate event registration, and bumped CounterStrikeSharp.API dependency to 1.0.346.
2025-11-13 01:33:38 +01:00
26 changed files with 315 additions and 207 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.sln.DotSetti
CS2-SimpleAdmin_BanSoundModule — kopia
*.user
CLAUDE.md
/Modules/CS2-SimpleAdmin_BanSoundModule

View File

@@ -21,13 +21,12 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
public override string ModuleAuthor => "daffyy & Dliix66";
public override string ModuleVersion => "1.7.8-beta-5";
public override string ModuleAuthor => "daffyy";
public override string ModuleVersion => "1.7.9a";
public override void Load(bool hotReload)
{
Instance = this;
if (hotReload)
{
ServerLoaded = false;
@@ -47,7 +46,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
CachedPlayers.Clear();
BotPlayers.Clear();
foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsHLTV).ToArray())
foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && p is { Connected: PlayerConnectedState.Connected, IsHLTV: false }).ToArray())
{
if (!player.IsBot)
PlayerManager.LoadPlayerData(player, true);
@@ -84,9 +83,9 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
Unload(false);
}
AddTimer(6.0f, () => ReloadAdmins(null));
RegisterEvents();
AddTimer(0.5f, RegisterCommands.InitializeCommands);
AddTimer(3.0f, () => ReloadAdmins(null));
if (!CoreConfig.UnlockConCommands)
{
@@ -261,6 +260,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
CacheManager = null;
PlayersTimer?.Kill();
PlayersTimer = null;
UnregisterEvents();
if (hotReload)

View File

@@ -19,16 +19,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.346">
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.367">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="MySqlConnector" Version="2.5.0-beta.1" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="System.Linq.Async" Version="7.0.0-preview.1.g24680b5469" />
<PackageReference Include="ZLinq" Version="1.5.3" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" />
<PackageReference Include="ZLinq" Version="1.5.6" />
</ItemGroup>
<ItemGroup>

View File

@@ -27,7 +27,7 @@ public partial class CS2_SimpleAdmin
var targets = GetTarget(command);
if (targets == null) return;
var playersToTarget = targets.Players.Where(player => player is { IsValid: true, Connected: PlayerConnectedState.PlayerConnected, IsHLTV: false }).ToList();
var playersToTarget = targets.Players.Where(player => player is { IsValid: true, Connected: PlayerConnectedState.Connected, IsHLTV: false }).ToList();
if (playersToTarget.Count > 1 && Config.OtherSettings.DisableDangerousCommands || playersToTarget.Count == 0)
{
@@ -373,7 +373,7 @@ public partial class CS2_SimpleAdmin
var targets = GetTarget(command);
if (targets == null) return;
var playersToTarget = targets.Players.Where(player => player.IsValid && player.Connected == PlayerConnectedState.PlayerConnected && !player.IsHLTV).ToList();
var playersToTarget = targets.Players.Where(player => player.IsValid && player.Connected == PlayerConnectedState.Connected && !player.IsHLTV).ToList();
if (playersToTarget.Count > 1 && Config.OtherSettings.DisableDangerousCommands || playersToTarget.Count == 0)
{

View File

@@ -507,7 +507,7 @@ public partial class CS2_SimpleAdmin
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
await Server.NextWorldUpdateAsync(() =>
await Server.NextWorldUpdateAsync(() =>
{
AddTimer(1, () =>
{

View File

@@ -44,7 +44,7 @@ public partial class CS2_SimpleAdmin
/// <param name="command">Optional command info for logging.</param>
internal static void Slay(CCSPlayerController? caller, CCSPlayerController player, string? callerName = null, CommandInfo? command = null)
{
if (!player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected) return;
if (!player.IsValid || player.Connected != PlayerConnectedState.Connected) return;
if (!caller.CanTarget(player)) return;
// Set default caller name if not provided
@@ -93,7 +93,7 @@ public partial class CS2_SimpleAdmin
playersToTarget.ForEach(player =>
{
if (player.Connected != PlayerConnectedState.PlayerConnected)
if (player.Connected != PlayerConnectedState.Connected)
return;
if (caller!.CanTarget(player))
@@ -207,7 +207,7 @@ public partial class CS2_SimpleAdmin
internal static void ChangeTeam(CCSPlayerController? caller, CCSPlayerController player, string teamName, CsTeam teamNum, bool kill, CommandInfo? command = null)
{
// Check if the player is valid and connected
if (!player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected)
if (!player.IsValid || player.Connected != PlayerConnectedState.Connected)
return;
// Ensure the caller can target the player
@@ -284,7 +284,7 @@ public partial class CS2_SimpleAdmin
playersToTarget.ForEach(player =>
{
// Check if the player is connected and can be targeted
if (player.Connected != PlayerConnectedState.PlayerConnected || !caller!.CanTarget(player))
if (player.Connected != PlayerConnectedState.Connected || !caller!.CanTarget(player))
return;
// Determine message key and arguments for the rename notification
@@ -330,7 +330,7 @@ public partial class CS2_SimpleAdmin
playersToTarget.ForEach(player =>
{
// Check if the player is connected and can be targeted
if (player.Connected != PlayerConnectedState.PlayerConnected || !caller!.CanTarget(player))
if (player.Connected != PlayerConnectedState.Connected || !caller!.CanTarget(player))
return;
// Determine message key and arguments for the rename notification
@@ -379,7 +379,7 @@ public partial class CS2_SimpleAdmin
return;
destinationPlayer = targets.Players.FirstOrDefault(p =>
p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE });
p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE });
if (destinationPlayer == null || !caller.CanTarget(destinationPlayer) || caller.PlayerPawn.Value == null)
return;
@@ -399,7 +399,7 @@ public partial class CS2_SimpleAdmin
return;
playersToTeleport = targets.Players
.Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p))
.Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p))
.ToList();
if (!playersToTeleport.Any())
@@ -476,7 +476,7 @@ public partial class CS2_SimpleAdmin
destinationPlayer = caller;
playersToTeleport = targets.Players
.Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p))
.Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p))
.ToList();
}
else
@@ -486,7 +486,7 @@ public partial class CS2_SimpleAdmin
return;
destinationPlayer = destination.Players.FirstOrDefault(p =>
p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE });
p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE });
if (destinationPlayer == null)
return;
@@ -497,7 +497,7 @@ public partial class CS2_SimpleAdmin
return;
playersToTeleport = targets.Players
.Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller!.CanTarget(p))
.Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller!.CanTarget(p))
.ToList();
}

View File

@@ -40,6 +40,7 @@ public interface IDatabaseProvider
string GetUpdateBanStatusQuery();
string GetExpireBansQuery(bool multiServer);
string GetExpireIpBansQuery(bool multiServer);
string GetExpireOldPlayerIpsQuery();
// MuteManager
string GetAddMuteQuery(bool includePlayerName);

View File

@@ -15,7 +15,7 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
cmd.CommandText = "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci';";
await cmd.ExecuteNonQueryAsync();
cmd.CommandText = "SET time_zone = '+00:00';";
// cmd.CommandText = "SET time_zone = '+00:00';";
await cmd.ExecuteNonQueryAsync();
return connection;
@@ -252,6 +252,11 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
: "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid";
}
public string GetExpireOldPlayerIpsQuery()
{
return "DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime";
}
public string GetAddMuteQuery(bool includePlayerName) =>
includePlayerName
? """

View File

@@ -167,6 +167,9 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
? "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime"
: "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid";
public string GetExpireOldPlayerIpsQuery() =>
"DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime";
public string GetAdminsQuery() =>
"""
SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends

View File

@@ -23,7 +23,7 @@ public partial class CS2_SimpleAdmin
{
RegisterListener<Listeners.OnMapStart>(OnMapStart);
// RegisterListener<Listeners.OnClientConnect>(OnClientConnect);
RegisterListener<Listeners.OnClientConnect>(OnClientConnect);
// RegisterListener<Listeners.OnClientConnect>(OnClientConnect);
RegisterListener<Listeners.OnClientConnected>(OnClientConnected);
RegisterListener<Listeners.OnGameServerSteamAPIActivated>(OnGameServerSteamAPIActivated);
if (Config.OtherSettings.UserMessageGagChatType)
@@ -77,7 +77,7 @@ public partial class CS2_SimpleAdmin
new ServerManager().LoadServerData();
}
[GameEventHandler(HookMode.Pre)]
[GameEventHandler]
public HookResult OnClientDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
{
if (@event.Reason is 149 or 6)
@@ -92,13 +92,14 @@ public partial class CS2_SimpleAdmin
if (player == null || !player.IsValid || player.IsHLTV)
return HookResult.Continue;
BotPlayers.Remove(player);
CachedPlayers.Remove(player);
BotPlayers.Remove(player);
SilentPlayers.Remove(player.Slot);
if (player.IsBot)
{
return HookResult.Continue;
}
#if DEBUG
Logger.LogCritical("[OnClientDisconnect] After Check");
@@ -176,6 +177,9 @@ public partial class CS2_SimpleAdmin
if (player == null || !player.IsValid || player.IsBot)
return;
if (!CachedPlayers.Contains(player))
CachedPlayers.Add(player);
PlayerManager.LoadPlayerData(player);
}
@@ -255,7 +259,7 @@ public partial class CS2_SimpleAdmin
{
var player = Utilities.GetPlayerFromSteamId(list.Key);
if (player == null || !player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected)
if (player == null || !player.IsValid || player.Connected != PlayerConnectedState.Connected)
continue;
if (player.PlayerName.Equals(list.Value))
@@ -324,7 +328,7 @@ public partial class CS2_SimpleAdmin
? Utilities.GetPlayerFromUserid(userId)
: null;
if (target == null || !target.IsValid || target.Connected != PlayerConnectedState.PlayerConnected)
if (target == null || !target.IsValid || target.Connected != PlayerConnectedState.Connected)
return HookResult.Continue;
return !player.CanTarget(target) ? HookResult.Stop : HookResult.Continue;
@@ -444,49 +448,32 @@ public partial class CS2_SimpleAdmin
private void OnMapStart(string mapName)
{
if (!ServerLoaded || ServerId == null)
AddTimer(2.0f, OnGameServerSteamAPIActivated);
if (Config.OtherSettings.ReloadAdminsEveryMapChange && ServerLoaded && ServerId != null)
AddTimer(5.0f, () => ReloadAdmins(null));
ReloadAdmins(null);
AddTimer(1.0f, ServerManager.CheckHibernationStatus);
if (!ServerLoaded || ServerId == null)
AddTimer(1.5f, OnGameServerSteamAPIActivated);
// AddTimer(34, () =>
// {
// if (!ServerLoaded)
// OnGameServerSteamAPIActivated();
// });
GodPlayers.Clear();
SilentPlayers.Clear();
PlayerPenaltyManager.RemoveAllPenalties();
}
[GameEventHandler]
public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info)
{
var player = @event.Userid;
if (player is null || @event.Attacker is null || player.PlayerPawn?.Value?.LifeState != (int)LifeState_t.LIFE_ALIVE || player.PlayerPawn.Value == null)
return HookResult.Continue;
if (!GodPlayers.Contains(player.Slot)) return HookResult.Continue;
player.PlayerPawn.Value.Health = player.PlayerPawn.Value.MaxHealth;
player.PlayerPawn.Value.ArmorValue = 100;
return HookResult.Continue;
}
[GameEventHandler]
public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
{
var player = @event.Userid;
if (player?.UserId == null || !player.IsValid || player.IsHLTV ||
player.Connected != PlayerConnectedState.PlayerConnected || !PlayersInfo.ContainsKey(player.SteamID) ||
player.Connected != PlayerConnectedState.Connected || !PlayersInfo.ContainsKey(player.SteamID) ||
@event.Attacker == null)
return HookResult.Continue;
@@ -512,17 +499,13 @@ public partial class CS2_SimpleAdmin
public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info)
{
var player = @event.Userid;
if (player == null || !player.IsValid || player.IsBot)
if (player == null || !player.IsValid || player.IsBot || !SilentPlayers.Contains(player.Slot))
return HookResult.Continue;
if (!SilentPlayers.Contains(player.Slot))
return HookResult.Continue;
if (@event is not { Oldteam: <= 1, Team: >= 1 }) return HookResult.Continue;
if (@event is { Oldteam: <= 1, Team: >= 1 })
{
SilentPlayers.Remove(player.Slot);
SimpleAdminApi?.OnAdminToggleSilentEvent(player.Slot, false);
}
SilentPlayers.Remove(player.Slot);
SimpleAdminApi?.OnAdminToggleSilentEvent(player.Slot, false);
return HookResult.Continue;
}

View File

@@ -11,12 +11,14 @@ using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CS2_SimpleAdminApi;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using CounterStrikeSharp.API.Core.Plugin.Host;
using CounterStrikeSharp.API.Modules.Entities.Constants;
@@ -64,7 +66,7 @@ internal static class Helper
public static List<CCSPlayerController> GetValidPlayers()
{
return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().Where(p => p.Connected == PlayerConnectedState.PlayerConnected).ToList();
return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().Where(p => p.IsValid && p.Connected == PlayerConnectedState.Connected).ToList();
}
public static List<CCSPlayerController> GetValidPlayersWithBots()
@@ -855,26 +857,34 @@ internal static class Helper
}
}
public static void UpdateConfig<T>(T config) where T : BasePluginConfig, new()
public static void UpdateConfig(BasePluginConfig config)
{
// get newest config version
var newCfgVersion = new T().Version;
var configType = config.GetType();
var newCfgVersion = ((BasePluginConfig)Activator.CreateInstance(configType)!).Version;
// loaded config is up to date
if (config.Version == newCfgVersion)
return;
// update the version
config.Version = newCfgVersion;
// Load existing JSON file and update version property
if (!File.Exists(CfgPath))
return;
// serialize the updated config back to json
var updatedJsonContent = JsonSerializer.Serialize(config,
new JsonSerializerOptions
var json = File.ReadAllText(CfgPath);
var node = JsonNode.Parse(json);
if (node != null)
{
node["Version"] = newCfgVersion;
var updatedJsonContent = node.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
File.WriteAllText(CfgPath, updatedJsonContent);
File.WriteAllText(CfgPath, updatedJsonContent);
}
}
public static void TryLogCommandOnDiscord(CCSPlayerController? caller, string commandString)

View File

@@ -431,6 +431,9 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string
var ipBansTime = currentTime.AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans);
sql = databaseProvider.GetExpireIpBansQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode);
await connection.ExecuteAsync(sql, new { ipBansTime, CS2_SimpleAdmin.ServerId });
sql = databaseProvider.GetExpireOldPlayerIpsQuery();
await connection.ExecuteAsync(sql, new { ipBansTime });
}
}
catch (Exception)

View File

@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using CS2_SimpleAdmin.Database;
using CS2_SimpleAdmin.Models;
using Dapper;
using Microsoft.Extensions.Logging;
using ZLinq;
namespace CS2_SimpleAdmin.Managers;
@@ -16,6 +17,7 @@ internal class CacheManager: IDisposable
private HashSet<uint> _cachedIgnoredIps = [];
private DateTime _lastUpdateTime = DateTime.MinValue;
private DateTime? _lastDatabaseTime = null; // Track actual time from database
private bool _isInitialized;
private bool _disposed;
@@ -156,13 +158,20 @@ internal class CacheManager: IDisposable
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
IEnumerable<BanRecord> updatedBans;
// Get current time from database in local timezone (CURRENT_TIMESTAMP uses session timezone, not UTC)
var currentDatabaseTime = await connection.QueryFirstAsync<DateTime>("SELECT CURRENT_TIMESTAMP");
// Optimization: Only get IDs for comparison if we need to check for deletions
// Most of the time bans are just added/updated, not deleted
HashSet<int>? allIds = null;
if (CS2_SimpleAdmin.Instance.Config.MultiServerMode)
{
updatedBans = (await connection.QueryAsync<BanRecord>(
// Use previous database time or start from far past if first run
var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue;
// Get recently updated bans by timestamp (using database time to avoid timezone issues)
var updatedBans_Query = (await connection.QueryAsync<BanRecord>(
"""
SELECT id AS Id,
player_name AS PlayerName,
@@ -171,33 +180,68 @@ internal class CacheManager: IDisposable
status AS Status
FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC
""",
new { lastUpdate = _lastUpdateTime }
));
new { lastUpdate = lastCheckTime }
)).ToList();
// Detect changes: new bans or status changes
var updatedList = new List<BanRecord>();
foreach (var ban in updatedBans_Query)
{
if (!_banCache.TryGetValue(ban.Id, out var cachedBan))
{
// New ban
updatedList.Add(ban);
}
else if (cachedBan.Status != ban.Status)
{
// Status changed
updatedList.Add(ban);
}
}
// Optimization: Only fetch all IDs if there were updates
var updatedList = updatedBans.ToList();
if (updatedList.Count > 0)
{
allIds = (await connection.QueryAsync<int>("SELECT id FROM sa_bans")).ToHashSet();
}
updatedBans = updatedList;
// Update last check time to current database time
_lastDatabaseTime = currentDatabaseTime;
}
else
{
updatedBans = (await connection.QueryAsync<BanRecord>(
// Use previous database time or start from far past if first run
var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue;
// Get recently updated bans for this server by timestamp (using database time to avoid timezone issues)
var updatedBans_Query = (await connection.QueryAsync<BanRecord>(
"""
SELECT id AS Id,
player_name AS PlayerName,
player_steamid AS PlayerSteamId,
player_ip AS PlayerIp,
status AS Status
FROM `sa_bans` WHERE (updated_at > @lastUpdate OR created > @lastUpdate) AND server_id = @serverId 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 { lastUpdate = _lastUpdateTime, serverId = CS2_SimpleAdmin.ServerId }
));
new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime }
)).ToList();
// Detect changes: new bans or status changes
var updatedList = new List<BanRecord>();
foreach (var ban in updatedBans_Query)
{
if (!_banCache.TryGetValue(ban.Id, out var cachedBan))
{
// New ban
updatedList.Add(ban);
}
else if (cachedBan.Status != ban.Status)
{
// Status changed
updatedList.Add(ban);
}
}
// Optimization: Only fetch all IDs if there were updates
var updatedList = updatedBans.ToList();
if (updatedList.Count > 0)
{
allIds = (await connection.QueryAsync<int>(
@@ -206,6 +250,9 @@ internal class CacheManager: IDisposable
)).ToHashSet();
}
updatedBans = updatedList;
// Update last check time to current database time
_lastDatabaseTime = currentDatabaseTime;
}
// Optimization: Only process deletions if we have the full ID list
@@ -276,16 +323,19 @@ internal class CacheManager: IDisposable
}
// Update cache with new/modified bans
var hasUpdates = false;
var needsRebuild = false;
foreach (var ban in updatedBans)
{
if (_banCache.TryGetValue(ban.Id, out var oldBan) && oldBan.Status != ban.Status)
{
// Ban status changed (e.g., ACTIVE -> EXPIRED/UNBANNED), need to rebuild indexes
needsRebuild = true;
}
_banCache.AddOrUpdate(ban.Id, ban, (_, _) => ban);
hasUpdates = true;
}
// Always rebuild indexes if there were any updates
// This ensures status changes (ACTIVE -> UNBANNED) are reflected
if (hasUpdates)
// Rebuild indexes if there were updates or status changes
if (updatedBans.Any() || needsRebuild)
{
RebuildIndexes();
}
@@ -436,17 +486,21 @@ internal class CacheManager: IDisposable
record = steamRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (record != null)
{
if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
(!record.PlayerSteamId.HasValue))
// Double-check the ban is still active in cache (handle race conditions)
if (_banCache.TryGetValue(record.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE)
{
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
}
if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
(!record.PlayerSteamId.HasValue))
{
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
}
return true;
return true;
}
}
}
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0)
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress))
return false;
if (string.IsNullOrEmpty(ipAddress) ||
@@ -456,6 +510,11 @@ internal class CacheManager: IDisposable
record = ipRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (record == null) return false;
// Double-check the ban is still active in cache (handle race conditions)
if (!_banCache.TryGetValue(record.Id, out var cachedBanIp) || cachedBanIp.StatusEnum != BanStatus.ACTIVE)
return false;
if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
(!record.PlayerSteamId.HasValue && steamId.HasValue))
{
@@ -547,52 +606,100 @@ internal class CacheManager: IDisposable
var activeBan = steamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
if (activeBan != null)
{
if (string.IsNullOrEmpty(activeBan.PlayerName) || string.IsNullOrEmpty(activeBan.PlayerIp))
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
// Double-check the ban is still active in cache (handle race conditions)
if (_banCache.TryGetValue(activeBan.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE)
{
if (string.IsNullOrEmpty(activeBan.PlayerName) || string.IsNullOrEmpty(activeBan.PlayerIp) && !string.IsNullOrEmpty(ipAddress))
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true;
}
}
}
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress))
return false;
if (!IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt))
return false;
if (_cachedIgnoredIps.Contains(ipUInt))
return false;
// Direct ip ban (ban record has player_ip set)
if (_ipIndex.TryGetValue(ipUInt, out var ipBanRecords))
{
var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (ipBan != null && _banCache.TryGetValue(ipBan.Id, out var cachedIpBan) && cachedIpBan.StatusEnum == BanStatus.ACTIVE)
{
var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans;
if (expireOldIpBans <= 0 || ipBan.Created >= Time.ActualDateTime().AddDays(-expireOldIpBans))
{
if (string.IsNullOrEmpty(ipBan.PlayerName))
ipBan.PlayerName = playerName;
ipBan.PlayerSteamId ??= steamId;
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true;
}
}
}
// Multiaccount ban - check if other accounts using current ip are banned
if (!_playerIpsCache.IsEmpty)
{
foreach (var (otherSteamId, ipSet) in _playerIpsCache)
{
// Skip current player
if (otherSteamId == steamId)
continue;
// Check if this ip is in the other accounts ip history
if (ipSet.All(record => record.Ip != ipUInt)) continue;
// Found another account using this ip - check if its banned
if (!_steamIdIndex.TryGetValue(otherSteamId, out var otherSteamBans)) continue;
var activeBan = otherSteamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) ||
cachedBan.StatusEnum != BanStatus.ACTIVE) continue;
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true;
}
}
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0)
// Multiaccount ban - check if this player used any ip where other banned accounts are connected
// Search sa_players_ips for all accounts sharing the same ips as current player
if (!CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
return false;
if (!_playerIpsCache.TryGetValue(steamId, out var ipData))
if (!_playerIpsCache.TryGetValue(steamId, out var playerIps))
return false;
var cutoff = Time.ActualDateTime().AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans);
var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
if (ipAddress != null && IpHelper.TryConvertIpToUint(ipAddress, out var ipAsUint))
// For each ip the player used (current or historical)
foreach (var playerIpRecord in playerIps)
{
if (!_cachedIgnoredIps.Contains(ipAsUint))
// Search sa_players_ips for other accounts using this same ip (as uint)
foreach (var (otherSteamId, otherIpSet) in _playerIpsCache)
{
ipData.Add(new IpRecord(ipAsUint, Time.ActualDateTime().AddSeconds(-2), unknownName));
if (otherSteamId == steamId)
continue;
// Check if this other account used the player ip
if (otherIpSet.All(record => record.Ip != playerIpRecord.Ip))
continue;
// Check if this other account is banned
if (!_steamIdIndex.TryGetValue(otherSteamId, out var otherSteamBans))
continue;
var activeBan = otherSteamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) ||
cachedBan.StatusEnum != BanStatus.ACTIVE)
continue;
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true;
}
}
foreach (var ipRecord in ipData)
{
if (ipRecord.UsedAt < cutoff || _cachedIgnoredIps.Contains(ipRecord.Ip))
continue;
if (!_ipIndex.TryGetValue(ipRecord.Ip, out var banRecords))
continue;
var activeBan = banRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (activeBan == null)
continue;
if (string.IsNullOrEmpty(activeBan.PlayerName))
activeBan.PlayerName = unknownName;
activeBan.PlayerSteamId ??= steamId;
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true;
}
return false;
}

View File

@@ -13,7 +13,7 @@ namespace CS2_SimpleAdmin.Managers;
internal class PlayerManager
{
private readonly SemaphoreSlim _loadPlayerSemaphore = new(5);
private readonly SemaphoreSlim _loadPlayerSemaphore = new(6);
private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config;
/// <summary>
@@ -52,75 +52,40 @@ internal class PlayerManager
{
await _loadPlayerSemaphore.WaitAsync();
if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId))
// Save ip address before ban check
await SavePlayerIpAddress(steamId, playerName, ipAddress);
// Always check bans first, regardless of PlayersInfo state
var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch
{
var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch
0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null),
_ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp
? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId,
ipAddress)
: CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress)
};
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)
{
CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] KICKING {playerName} ({steamId})");
await Server.NextWorldUpdateAsync(() =>
{
0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null),
_ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp
? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId,
ipAddress)
: CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress)
};
CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Executing kick for {playerName}");
Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED);
});
// CS2_SimpleAdmin._logger?.LogInformation($"Player {playerName} ({steamId} - {ipAddress}) is banned? {isBanned.ToString()}");
if (isBanned)
{
await Server.NextWorldUpdateAsync(() =>
{
// CS2_SimpleAdmin._logger?.LogInformation($"Kicking {playerName}");
Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED);
});
return;
}
return;
}
if (fullConnect)
if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId))
{
var playerInfo = new PlayerInfo(userId, slot, new SteamID(steamId), playerName, ipAddress);
CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo;
await Server.NextWorldUpdateAsync(() =>
if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null)
{
if (!CS2_SimpleAdmin.CachedPlayers.Contains(player))
CS2_SimpleAdmin.CachedPlayers.Add(player);
});
if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null &&
CS2_SimpleAdmin.PlayersInfo[steamId] != null)
{
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
// Eliminates the need for SELECT COUNT and duplicate UPDATE queries
var steamId64 = CS2_SimpleAdmin.PlayersInfo[steamId].SteamId.SteamId64;
var ipUint = IpHelper.IpToUint(ipAddress);
// Use database-specific UPSERT query (handles MySQL vs SQLite syntax differences)
var upsertQuery = CS2_SimpleAdmin.DatabaseProvider.GetUpsertPlayerIpQuery();
await connection.ExecuteAsync(upsertQuery, new
{
SteamID = steamId64,
playerName,
IPAddress = ipUint
});
// // Cache will be updated on next refresh cycle
// if (!CS2_SimpleAdmin.Instance.CacheManager.HasIpForPlayer(steamId, ipAddress))
// {
// // IP association will be reflected after cache refresh
// }
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError(
$"Unable to save ip address for {playerInfo.Name} ({ipAddress}): {ex.Message}");
}
playerInfo.AccountsAssociated =
CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable()
.Select(x => (x.SteamId, x.PlayerName)).ToList() ?? [];
@@ -207,7 +172,7 @@ internal class PlayerManager
AdminManager.PlayerHasPermissions(
new SteamID(p.SteamID),
"@css/ban")) &&
p.Connected == PlayerConnectedState.PlayerConnected &&
p.Connected == PlayerConnectedState.Connected &&
!CS2_SimpleAdmin.AdminDisabledJoinComms
.Contains(p.SteamID)))
{
@@ -254,12 +219,45 @@ internal class PlayerManager
_loadPlayerSemaphore.Release();
}
});
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name))
{
player.Rename(name);
}
}
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>
/// Periodically checks the status of online players and applies timers for speed, gravity,
/// and penalty expiration validation.
@@ -287,9 +285,6 @@ internal class PlayerManager
// Optimization: Get players once and avoid allocating anonymous types
var validPlayers = Helper.GetValidPlayers();
if (validPlayers.Count == 0)
return;
// 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);
foreach (var p in validPlayers)

View File

@@ -31,7 +31,7 @@ public class ServerManager
/// </summary>
public void LoadServerData()
{
CS2_SimpleAdmin.Instance.AddTimer(2.0f, () =>
CS2_SimpleAdmin.Instance.AddTimer(1.0f, () =>
{
if (CS2_SimpleAdmin.ServerLoaded || CS2_SimpleAdmin.DatabaseProvider == null) return;
@@ -103,14 +103,12 @@ public class ServerManager
CS2_SimpleAdmin.ServerId = serverId;
CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress);
if (CS2_SimpleAdmin.ServerId != null)
{
await Server.NextWorldUpdateAsync(() => CS2_SimpleAdmin.Instance.ReloadAdmins(null));
}
CS2_SimpleAdmin.ServerLoaded = true;
CS2_SimpleAdmin.ServerLoaded = true;
if (CS2_SimpleAdmin.Instance.CacheManager != null)
{
await CS2_SimpleAdmin.Instance.CacheManager.InitializeCacheAsync();
}
}
catch (Exception ex)
{

View File

@@ -33,7 +33,7 @@ internal class WarnManager(IDatabaseProvider? databaseProvider)
playerName = player.Name,
adminSteamid = issuer?.SteamId.SteamId64 ?? 0,
adminName = issuer?.Name ?? CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console",
muteReason = reason,
warnReason = reason,
duration = time,
ends = futureTime,
created = now,
@@ -42,7 +42,7 @@ internal class WarnManager(IDatabaseProvider? databaseProvider)
return warnId;
}
catch
catch(Exception)
{
return null;
}
@@ -73,7 +73,7 @@ internal class WarnManager(IDatabaseProvider? databaseProvider)
playerSteamid = playerSteamId,
adminSteamid = issuer?.SteamId.SteamId64 ?? 0,
adminName = issuer?.Name ?? CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console",
muteReason = reason,
warnReason = reason,
duration = time,
ends = futureTime,
created = now,

View File

@@ -25,6 +25,9 @@ public record BanRecord
[Column("player_ip")]
public string? PlayerIp { get; set; }
[Column("created")]
public DateTime Created { get; init; }
[Column("status")]
public required string Status { get; init; }

View File

@@ -1 +1 @@
1.7.8-beta-5
1.7.9a

View File

@@ -40,7 +40,6 @@ public partial class CS2_SimpleAdmin
internal static readonly HashSet<ulong> AdminDisabledJoinComms = [];
// Player Management
private static readonly HashSet<int> GodPlayers = [];
internal static readonly HashSet<int> SilentPlayers = [];
internal static readonly Dictionary<ulong, string> RenamedPlayers = [];
internal static readonly ConcurrentDictionary<ulong, PlayerInfo> PlayersInfo = [];

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.346" />
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.367" />
</ItemGroup>
</Project>

View File

@@ -45,7 +45,7 @@ public class CS2_SimpleAdmin_BanSoundModule: BasePlugin
foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsBot))
{
var filter = new RecipientFilter(player);
player?.EmitSound("bansound", volume: 0.9f, recipients: filter);
player?.EmitSound("bansound", volume: 0.75f, recipients: filter);
}
}
}

View File

@@ -14,7 +14,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.340" />
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.346" />
</ItemGroup>
<ItemGroup>