diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2434cd..d1b0b19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Get Version id: get_version @@ -67,28 +67,12 @@ jobs: - name: Zip Main Build Output run: zip -r CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip ${{ env.OUTPUT_PATH }} - - name: Extract & Zip StatusBlocker Linux - run: | - mkdir -p statusblocker-linux && - tar -xzf Modules/CS2-SimpleAdmin_StealthModule/METAMOD\ PLUGIN/StatusBlocker-v*-linux.tar.gz -C statusblocker-linux && - cd statusblocker-linux && - zip -r ../StatusBlocker-linux-${{ steps.get_version.outputs.VERSION }}.zip ./* - - - name: Extract & Zip StatusBlocker Windows - run: | - mkdir -p statusblocker-windows && - tar -xzf Modules/CS2-SimpleAdmin_StealthModule/METAMOD\ PLUGIN/StatusBlocker-v*-windows.tar.gz -C statusblocker-windows && - cd statusblocker-windows && - zip -r ../StatusBlocker-windows-${{ steps.get_version.outputs.VERSION }}.zip ./* - - name: Upload all artifacts uses: actions/upload-artifact@v4 with: name: CS2-SimpleAdmin-Build-Artifacts path: | CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip - StatusBlocker-linux-${{ steps.get_version.outputs.VERSION }}.zip - StatusBlocker-windows-${{ steps.get_version.outputs.VERSION }}.zip publish: needs: build @@ -109,8 +93,6 @@ jobs: with: artifacts: | CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}.zip - StatusBlocker-linux-${{ needs.build.outputs.build_version }}.zip - StatusBlocker-windows-${{ needs.build.outputs.build_version }}.zip name: "CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}" tag: "build-${{ needs.build.outputs.build_version }}" body: | @@ -121,8 +103,5 @@ jobs: After the first launch, configure the plugin using the JSON config file at: addons/counterstrikesharp/configs/plugins/CS2-SimpleAdmin/CS2-SimpleAdmin.json - - StatusBlocker: - Place the plugin files directly into the addons directory. - This plugin is a Metamod module for the StealthModule and does not require a subfolder. Remember to restart or reload your game server after installing and configuring the plugins. diff --git a/.gitignore b/.gitignore index 28e335d..1850b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.sln.DotSetti CS2-SimpleAdmin_BanSoundModule — kopia *.user CLAUDE.md +/Modules/CS2-SimpleAdmin_BanSoundModule +/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN diff --git a/CS2-SimpleAdmin/Api/CS2_SimpleAdminApi.cs b/CS2-SimpleAdmin/Api/CS2_SimpleAdminApi.cs index 1ef8886..868539e 100644 --- a/CS2-SimpleAdmin/Api/CS2_SimpleAdminApi.cs +++ b/CS2-SimpleAdmin/Api/CS2_SimpleAdminApi.cs @@ -1,5 +1,6 @@ using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Commands; +using CounterStrikeSharp.API.Core.Translations; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Commands.Targeting; using CounterStrikeSharp.API.Modules.Entities; @@ -205,6 +206,14 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi Menus.MenuManager.Instance.RegisterCategory(categoryId, categoryName, permission); } + public void RegisterMenuCategory(string categoryId, string categoryNameKey, string permission, object moduleLocalizer) + { + if (moduleLocalizer is not IStringLocalizer localizer) + throw new InvalidOperationException("moduleLocalizer must be an IStringLocalizer instance"); + + Menus.MenuManager.Instance.RegisterCategory(categoryId, categoryNameKey, permission, localizer); + } + public void RegisterMenu(string categoryId, string menuId, string menuName, Func menuFactory, string? permission = null, string? commandName = null) { @@ -263,6 +272,39 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi } } + public void RegisterMenu(string categoryId, string menuId, string menuNameKey, + Func menuFactory, string? permission, string? commandName, object moduleLocalizer) + { + if (moduleLocalizer is not IStringLocalizer localizer) + throw new InvalidOperationException("moduleLocalizer must be an IStringLocalizer instance"); + + Menus.MenuManager.Instance.RegisterMenu(categoryId, menuId, menuNameKey, BuilderFactory, permission, commandName, localizer); + return; + + MenuBuilder BuilderFactory(CCSPlayerController player) + { + var context = new MenuContext(categoryId, menuId, menuNameKey, permission, commandName); + + if (menuFactory(player, context) is not MenuBuilder menuBuilder) + throw new InvalidOperationException("Menu factory must return MenuBuilder"); + + // Dodaj automatyczną obsługę przycisku 'Wróć' + menuBuilder.WithBackAction(p => + { + if (Menus.MenuManager.Instance.GetMenuCategories().TryGetValue(categoryId, out var category)) + { + Menus.MenuManager.Instance.CreateCategoryMenuPublic(category, p).OpenMenu(p); + } + else + { + Menus.MenuManager.Instance.OpenMainMenu(p); + } + }); + + return menuBuilder; + } + } + public void UnregisterMenu(string categoryId, string menuId) { @@ -289,7 +331,30 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi public object CreateMenuWithBack(MenuContext context, CCSPlayerController player) { - return CreateMenuWithBack(context.MenuTitle, context.CategoryId, player); + // Get translated title if module has localizer + string title = context.MenuTitle; + + if (Menus.MenuManager.Instance.GetMenuCategories().TryGetValue(context.CategoryId, out var category)) + { + // Check if this specific menu has a localizer + if (category.MenuLocalizers.TryGetValue(context.MenuId, out var menuLocalizer)) + { + using (new WithTemporaryCulture(player.GetLanguage())) + { + title = menuLocalizer[context.MenuTitle] ?? context.MenuTitle; + } + } + // Fallback to category localizer + else if (category.ModuleLocalizer != null) + { + using (new WithTemporaryCulture(player.GetLanguage())) + { + title = category.ModuleLocalizer[context.MenuTitle] ?? context.MenuTitle; + } + } + } + + return CreateMenuWithBack(title, context.CategoryId, player); } public List GetValidPlayers() @@ -321,7 +386,30 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi public object CreateMenuWithPlayers(MenuContext context, CCSPlayerController admin, Func filter, Action onSelect) { - return CreateMenuWithPlayers(context.MenuTitle, context.CategoryId, admin, filter, onSelect); + // Get translated title if module has localizer + string title = context.MenuTitle; + + if (Menus.MenuManager.Instance.GetMenuCategories().TryGetValue(context.CategoryId, out var category)) + { + // Check if this specific menu has a localizer + if (category.MenuLocalizers.TryGetValue(context.MenuId, out var menuLocalizer)) + { + using (new WithTemporaryCulture(admin.GetLanguage())) + { + title = menuLocalizer[context.MenuTitle] ?? context.MenuTitle; + } + } + // Fallback to category localizer + else if (category.ModuleLocalizer != null) + { + using (new WithTemporaryCulture(admin.GetLanguage())) + { + title = category.ModuleLocalizer[context.MenuTitle] ?? context.MenuTitle; + } + } + } + + return CreateMenuWithPlayers(title, context.CategoryId, admin, filter, onSelect); } public void AddMenuOption(object menu, string name, Action action, bool disabled = false, diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index d193b3c..a2a82ee 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -14,20 +14,19 @@ using MySqlConnector; namespace CS2_SimpleAdmin; -[MinimumApiVersion(300)] +[MinimumApiVersion(369)] public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig { internal static CS2_SimpleAdmin Instance { get; private set; } = new(); 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-3"; + public override string ModuleAuthor => "daffyy"; + public override string ModuleVersion => "1.8.2b"; public override void Load(bool hotReload) { Instance = this; - if (hotReload) { ServerLoaded = false; @@ -47,7 +46,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig 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); @@ -56,14 +55,12 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig(GameData.GetSignature("CBasePlayerController_SetPawn")); SimpleAdminApi = new Api.CS2_SimpleAdminApi(); Capabilities.RegisterPluginCapability(ICS2_SimpleAdminApi.PluginCapability, () => SimpleAdminApi); - + PlayersTimer?.Kill(); PlayersTimer = null; PlayerManager.CheckPlayersTimer(); @@ -84,9 +81,9 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig ReloadAdmins(null)); RegisterEvents(); AddTimer(0.5f, RegisterCommands.InitializeCommands); + AddTimer(3.0f, () => ReloadAdmins(null)); if (!CoreConfig.UnlockConCommands) { @@ -96,20 +93,11 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig - net8.0 + net10.0 CS2_SimpleAdmin enable enable @@ -19,16 +19,16 @@ - + none runtime compile; build; native; contentfiles; analyzers; buildtransitive - - + + - - + + diff --git a/CS2-SimpleAdmin/Commands/basebans.cs b/CS2-SimpleAdmin/Commands/basebans.cs index 761ecbd..f0a20ba 100644 --- a/CS2-SimpleAdmin/Commands/basebans.cs +++ b/CS2-SimpleAdmin/Commands/basebans.cs @@ -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) { diff --git a/CS2-SimpleAdmin/Commands/basecommands.cs b/CS2-SimpleAdmin/Commands/basecommands.cs index 8d95c33..9513cbc 100644 --- a/CS2-SimpleAdmin/Commands/basecommands.cs +++ b/CS2-SimpleAdmin/Commands/basecommands.cs @@ -501,13 +501,13 @@ public partial class CS2_SimpleAdmin Task.Run(async () => { - await PermissionManager.CrateGroupsJsonFile(); + 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(() => + await Server.NextWorldUpdateAsync(() => { AddTimer(1, () => { @@ -521,7 +521,7 @@ public partial class CS2_SimpleAdmin _logger?.LogInformation("Loaded admins!"); }); }); - }); + }); //_ = _adminManager.GiveAllGroupsFlags(); //_ = _adminManager.GiveAllFlags(); diff --git a/CS2-SimpleAdmin/Commands/playercommands.cs b/CS2-SimpleAdmin/Commands/playercommands.cs index 698c3f3..2330ccf 100644 --- a/CS2-SimpleAdmin/Commands/playercommands.cs +++ b/CS2-SimpleAdmin/Commands/playercommands.cs @@ -44,7 +44,7 @@ public partial class CS2_SimpleAdmin /// Optional command info for logging. 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(); } diff --git a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs index 25bc129..e2a4ae4 100644 --- a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs @@ -12,6 +12,9 @@ public interface IDatabaseProvider string GetBanSelectQuery(bool multiServer); string GetIpHistoryQuery(); string GetBanUpdateQuery(bool multiServer); + + // PlayerManager + string GetUpsertPlayerIpQuery(); // PermissionManager string GetAdminsQuery(); @@ -37,6 +40,7 @@ public interface IDatabaseProvider string GetUpdateBanStatusQuery(); string GetExpireBansQuery(bool multiServer); string GetExpireIpBansQuery(bool multiServer); + string GetExpireOldPlayerIpsQuery(); // MuteManager string GetAddMuteQuery(bool includePlayerName); diff --git a/CS2-SimpleAdmin/Database/Migrations/Mysql/016_OptimizeTablesAndIndexes.sql b/CS2-SimpleAdmin/Database/Migrations/Mysql/016_OptimizeTablesAndIndexes.sql index c674d89..a171504 100644 --- a/CS2-SimpleAdmin/Database/Migrations/Mysql/016_OptimizeTablesAndIndexes.sql +++ b/CS2-SimpleAdmin/Database/Migrations/Mysql/016_OptimizeTablesAndIndexes.sql @@ -1,33 +1,33 @@ --- Migration 016: Optimize tables and indexes --- Add proper indexes for all tables to improve query performance +-- -- Migration 016: Optimize tables and indexes +-- -- Add proper indexes for all tables to improve query performance --- Optimize sa_players_ips table indexes --- Add index on used_at for efficient date-based queries -ALTER TABLE `sa_players_ips` ADD INDEX IF NOT EXISTS `idx_used_at` (`used_at` DESC); +-- -- Optimize sa_players_ips table indexes +-- -- Add index on used_at for efficient date-based queries +-- ALTER TABLE `sa_players_ips` ADD INDEX IF NOT EXISTS `idx_used_at` (`used_at` DESC); --- Optimize sa_bans table indexes --- Add composite indexes for common query patterns -CREATE INDEX IF NOT EXISTS `idx_bans_steamid_status` ON `sa_bans` (`player_steamid`, `status`); -CREATE INDEX IF NOT EXISTS `idx_bans_ip_status` ON `sa_bans` (`player_ip`, `status`); -CREATE INDEX IF NOT EXISTS `idx_bans_status_ends` ON `sa_bans` (`status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_bans_server_status` ON `sa_bans` (`server_id`, `status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_bans_created` ON `sa_bans` (`created` DESC); +-- -- Optimize sa_bans table indexes +-- -- Add composite indexes for common query patterns +-- CREATE INDEX IF NOT EXISTS `idx_bans_steamid_status` ON `sa_bans` (`player_steamid`, `status`); +-- CREATE INDEX IF NOT EXISTS `idx_bans_ip_status` ON `sa_bans` (`player_ip`, `status`); +-- CREATE INDEX IF NOT EXISTS `idx_bans_status_ends` ON `sa_bans` (`status`, `ends`); +-- CREATE INDEX IF NOT EXISTS `idx_bans_server_status` ON `sa_bans` (`server_id`, `status`, `ends`); +-- CREATE INDEX IF NOT EXISTS `idx_bans_created` ON `sa_bans` (`created` DESC); --- Optimize sa_admins table indexes -CREATE INDEX IF NOT EXISTS `idx_admins_steamid` ON `sa_admins` (`player_steamid`); -CREATE INDEX IF NOT EXISTS `idx_admins_server_ends` ON `sa_admins` (`server_id`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_admins_ends` ON `sa_admins` (`ends`); +-- -- Optimize sa_admins table indexes +-- CREATE INDEX IF NOT EXISTS `idx_admins_steamid` ON `sa_admins` (`player_steamid`); +-- CREATE INDEX IF NOT EXISTS `idx_admins_server_ends` ON `sa_admins` (`server_id`, `ends`); +-- CREATE INDEX IF NOT EXISTS `idx_admins_ends` ON `sa_admins` (`ends`); --- Optimize sa_mutes table indexes (in addition to migration 014) --- Add index for expire queries -CREATE INDEX IF NOT EXISTS `idx_mutes_status_ends` ON `sa_mutes` (`status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_mutes_server_status` ON `sa_mutes` (`server_id`, `status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_mutes_created` ON `sa_mutes` (`created` DESC); +-- -- Optimize sa_mutes table indexes (in addition to migration 014) +-- -- Add index for expire queries +-- CREATE INDEX IF NOT EXISTS `idx_mutes_status_ends` ON `sa_mutes` (`status`, `ends`); +-- CREATE INDEX IF NOT EXISTS `idx_mutes_server_status` ON `sa_mutes` (`server_id`, `status`, `ends`); +-- CREATE INDEX IF NOT EXISTS `idx_mutes_created` ON `sa_mutes` (`created` DESC); --- Optimize sa_warns table indexes (if exists) -CREATE INDEX IF NOT EXISTS `idx_warns_steamid_status` ON `sa_warns` (`player_steamid`, `status`); -CREATE INDEX IF NOT EXISTS `idx_warns_status_ends` ON `sa_warns` (`status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_warns_server_status` ON `sa_warns` (`server_id`, `status`, `ends`); +-- -- Optimize sa_warns table indexes (if exists) +-- CREATE INDEX IF NOT EXISTS `idx_warns_steamid_status` ON `sa_warns` (`player_steamid`, `status`); +-- CREATE INDEX IF NOT EXISTS `idx_warns_status_ends` ON `sa_warns` (`status`, `ends`); +-- CREATE INDEX IF NOT EXISTS `idx_warns_server_status` ON `sa_warns` (`server_id`, `status`, `ends`); --- Add index on sa_servers for faster lookups -CREATE INDEX IF NOT EXISTS `idx_servers_hostname` ON `sa_servers` (`hostname`); +-- -- Add index on sa_servers for faster lookups +-- CREATE INDEX IF NOT EXISTS `idx_servers_hostname` ON `sa_servers` (`hostname`); diff --git a/CS2-SimpleAdmin/Database/Migrations/Sqlite/009_BanAllUsedIpAddress.sql b/CS2-SimpleAdmin/Database/Migrations/Sqlite/009_BanAllUsedIpAddress.sql index 33e57f2..e9ba400 100644 --- a/CS2-SimpleAdmin/Database/Migrations/Sqlite/009_BanAllUsedIpAddress.sql +++ b/CS2-SimpleAdmin/Database/Migrations/Sqlite/009_BanAllUsedIpAddress.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS `sa_players_ips` ( `steamid` INTEGER NOT NULL, - `address` INTEGER NOT NULL + `address` INTEGER NOT NULL, `used_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`steamid`, `address`) ); \ No newline at end of file diff --git a/CS2-SimpleAdmin/Database/Migrations/Sqlite/013_AddNameColumnToSaPlayerIpsTable.sql b/CS2-SimpleAdmin/Database/Migrations/Sqlite/013_AddNameColumnToSaPlayerIpsTable.sql index 87a80aa..02185a2 100644 --- a/CS2-SimpleAdmin/Database/Migrations/Sqlite/013_AddNameColumnToSaPlayerIpsTable.sql +++ b/CS2-SimpleAdmin/Database/Migrations/Sqlite/013_AddNameColumnToSaPlayerIpsTable.sql @@ -1,4 +1,4 @@ -TRUNCATE TABLE `sa_players_ips`; +DELETE FROM sa_players_ips; ALTER TABLE `sa_players_ips` ADD `name` VARCHAR(64) NULL DEFAULT NULL; CREATE INDEX IF NOT EXISTS `idx_sa_players_ips_used_at` ON `sa_players_ips` (`used_at` DESC); \ No newline at end of file diff --git a/CS2-SimpleAdmin/Database/Migrations/Sqlite/016_OptimizeTablesAndIndexes.sql b/CS2-SimpleAdmin/Database/Migrations/Sqlite/016_OptimizeTablesAndIndexes.sql index 749229a..e69de29 100644 --- a/CS2-SimpleAdmin/Database/Migrations/Sqlite/016_OptimizeTablesAndIndexes.sql +++ b/CS2-SimpleAdmin/Database/Migrations/Sqlite/016_OptimizeTablesAndIndexes.sql @@ -1,33 +0,0 @@ --- Migration 016: Optimize tables and indexes --- Add proper indexes for all tables to improve query performance - --- Optimize sa_players_ips table indexes --- Add index on used_at for efficient date-based queries -CREATE INDEX IF NOT EXISTS `idx_used_at` ON `sa_players_ips` (`used_at` DESC); - --- Optimize sa_bans table indexes --- Add composite indexes for common query patterns -CREATE INDEX IF NOT EXISTS `idx_bans_steamid_status` ON `sa_bans` (`player_steamid`, `status`); -CREATE INDEX IF NOT EXISTS `idx_bans_ip_status` ON `sa_bans` (`player_ip`, `status`); -CREATE INDEX IF NOT EXISTS `idx_bans_status_ends` ON `sa_bans` (`status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_bans_server_status` ON `sa_bans` (`server_id`, `status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_bans_created` ON `sa_bans` (`created` DESC); - --- Optimize sa_admins table indexes -CREATE INDEX IF NOT EXISTS `idx_admins_steamid` ON `sa_admins` (`player_steamid`); -CREATE INDEX IF NOT EXISTS `idx_admins_server_ends` ON `sa_admins` (`server_id`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_admins_ends` ON `sa_admins` (`ends`); - --- Optimize sa_mutes table indexes (in addition to migration 014) --- Add index for expire queries -CREATE INDEX IF NOT EXISTS `idx_mutes_status_ends` ON `sa_mutes` (`status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_mutes_server_status` ON `sa_mutes` (`server_id`, `status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_mutes_created` ON `sa_mutes` (`created` DESC); - --- Optimize sa_warns table indexes (if exists) -CREATE INDEX IF NOT EXISTS `idx_warns_steamid_status` ON `sa_warns` (`player_steamid`, `status`); -CREATE INDEX IF NOT EXISTS `idx_warns_status_ends` ON `sa_warns` (`status`, `ends`); -CREATE INDEX IF NOT EXISTS `idx_warns_server_status` ON `sa_warns` (`server_id`, `status`, `ends`); - --- Add index on sa_servers for faster lookups -CREATE INDEX IF NOT EXISTS `idx_servers_hostname` ON `sa_servers` (`hostname`); diff --git a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs index 9e79d93..fa9a27e 100644 --- a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs @@ -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; @@ -86,6 +86,17 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider return "SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY used_at DESC"; } + public string GetUpsertPlayerIpQuery() + { + return """ + INSERT INTO `sa_players_ips` (steamid, name, address, used_at) + VALUES (@SteamID, @playerName, @IPAddress, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + used_at = CURRENT_TIMESTAMP, + name = @playerName; + """; + } + public string GetBanUpdateQuery(bool multiServer) { return multiServer ? """ @@ -240,6 +251,11 @@ public class MySqlDatabaseProvider(string connectionString) : 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() + { + return "DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime"; + } public string GetAddMuteQuery(bool includePlayerName) => includePlayerName diff --git a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs index bd63d7b..d6186fb 100644 --- a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs @@ -83,6 +83,17 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider public string GetIpHistoryQuery() => "SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY used_at DESC"; + public string GetUpsertPlayerIpQuery() + { + return """ + INSERT INTO sa_players_ips (steamid, name, address, used_at) + VALUES (@SteamID, @playerName, @IPAddress, CURRENT_TIMESTAMP) + ON CONFLICT(steamid, address) DO UPDATE SET + used_at = CURRENT_TIMESTAMP, + name = @playerName; + """; + } + public string GetBanUpdateQuery(bool multiServer) => multiServer ? """ @@ -155,6 +166,9 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider multiServer ? "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() => """ diff --git a/CS2-SimpleAdmin/Events.cs b/CS2-SimpleAdmin/Events.cs index 4a3755a..8d0149a 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -23,7 +23,7 @@ public partial class CS2_SimpleAdmin { RegisterListener(OnMapStart); // RegisterListener(OnClientConnect); - RegisterListener(OnClientConnect); + // RegisterListener(OnClientConnect); RegisterListener(OnClientConnected); RegisterListener(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) @@ -91,14 +91,15 @@ public partial class CS2_SimpleAdmin if (player == null || !player.IsValid || player.IsHLTV) return HookResult.Continue; - - BotPlayers.Remove(player); - CachedPlayers.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); } @@ -249,13 +253,13 @@ public partial class CS2_SimpleAdmin player.DiePosition = null; } - AddTimer(0.41f, () => + AddTimer(0.5f, () => { foreach (var list in RenamedPlayers) { 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,13 +448,13 @@ 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, () => // { @@ -458,35 +462,18 @@ public partial class CS2_SimpleAdmin // 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 { Oldteam: <= 1, Team: >= 1 }) - { - SilentPlayers.Remove(player.Slot); - SimpleAdminApi?.OnAdminToggleSilentEvent(player.Slot, false); - } + if (@event is not { Oldteam: <= 1, Team: >= 1 }) return HookResult.Continue; + + SilentPlayers.Remove(player.Slot); + SimpleAdminApi?.OnAdminToggleSilentEvent(player.Slot, false); return HookResult.Continue; } diff --git a/CS2-SimpleAdmin/Helper.cs b/CS2-SimpleAdmin/Helper.cs index 089e083..aff807d 100644 --- a/CS2-SimpleAdmin/Helper.cs +++ b/CS2-SimpleAdmin/Helper.cs @@ -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 GetValidPlayers() { - return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().ToList(); + return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().Where(p => p.IsValid && p.Connected == PlayerConnectedState.Connected).ToList(); } public static List GetValidPlayersWithBots() @@ -426,7 +428,7 @@ internal static class Helper var communityUrl = caller != null ? "<" + new SteamID(caller.SteamID).ToCommunityUrl() + ">" : ""; var callerName = caller != null ? caller.PlayerName : CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console"; - _ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(Helper.GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command.GetCommandString])); + _ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command.GetCommandString])); } private static void SendDiscordLogMessage(CCSPlayerController? caller, string command, IStringLocalizer? localizer) @@ -583,27 +585,32 @@ internal static class Helper } public static void DisplayCenterMessage( - CCSPlayerController player, - string messageKey, - string? callerName = null, + CCSPlayerController player, + string messageKey, + string? callerName = null, params object[] messageArgs) { if (CS2_SimpleAdmin._localizer == null) return; + // Determine the localized message key var localizedMessageKey = $"{messageKey}"; var formattedMessageArgs = messageArgs.Select(arg => arg?.ToString() ?? string.Empty).ToArray(); + // Replace placeholder based on showActivityType for (var i = 0; i < formattedMessageArgs.Length; i++) { - var arg = formattedMessageArgs[i]; + var arg = formattedMessageArgs[i]; // Convert argument to string if not null + // Replace "CALLER" placeholder in the argument string formattedMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch { 1 => arg.Replace("CALLER", CS2_SimpleAdmin._localizer["sa_admin"]), + 2 => arg.Replace("CALLER", callerName ?? "Console"), _ => arg }; } + // Print the localized message to the center of the screen for the player using (new WithTemporaryCulture(player.GetLanguage())) { player.PrintToCenter(CS2_SimpleAdmin._localizer[localizedMessageKey, formattedMessageArgs.Cast().ToArray()]); @@ -850,26 +857,34 @@ internal static class Helper } } - public static void UpdateConfig(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) @@ -996,6 +1011,10 @@ public static class PluginInfo 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(" "); } } @@ -1026,7 +1045,9 @@ public static class Time { public static DateTime ActualDateTime() { - return DateTime.UtcNow; + if (CS2_SimpleAdmin.Instance.Config.DatabaseConfig.DatabaseType.ToLower().Equals("sqlite")) + return DateTime.UtcNow; + string timezoneId = CS2_SimpleAdmin.Instance.Config.Timezone; DateTime utcNow = DateTime.UtcNow; diff --git a/CS2-SimpleAdmin/Managers/BanManager.cs b/CS2-SimpleAdmin/Managers/BanManager.cs index 2aac526..5863b59 100644 --- a/CS2-SimpleAdmin/Managers/BanManager.cs +++ b/CS2-SimpleAdmin/Managers/BanManager.cs @@ -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) diff --git a/CS2-SimpleAdmin/Managers/CacheManager.cs b/CS2-SimpleAdmin/Managers/CacheManager.cs index 4e580c6..d02e036 100644 --- a/CS2-SimpleAdmin/Managers/CacheManager.cs +++ b/CS2-SimpleAdmin/Managers/CacheManager.cs @@ -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 _cachedIgnoredIps = []; private DateTime _lastUpdateTime = DateTime.MinValue; + private DateTime? _lastDatabaseTime = null; // Track actual time from database private bool _isInitialized; private bool _disposed; @@ -71,7 +73,7 @@ internal class CacheManager: IDisposable if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) { // Optimization: Load IP history and build cache in single pass - var ipHistory = await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>( + var ipHistory = await connection.QueryAsync( "SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY steamid, address, used_at DESC"); var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; @@ -82,24 +84,24 @@ internal class CacheManager: IDisposable foreach (var record in ipHistory) { // When we encounter a new steamid, save the previous one - if (record.steamid != currentSteamId && currentSteamId != 0) + if ((ulong)record.Steamid != currentSteamId && currentSteamId != 0) { _playerIpsCache[currentSteamId] = currentIpSet; currentIpSet = new HashSet(new IpRecordComparer()); latestIpTimestamps.Clear(); } - currentSteamId = record.steamid; + currentSteamId = (ulong)record.Steamid; // Only keep the latest timestamp for each IP - if (!latestIpTimestamps.TryGetValue(record.address, out var existingTimestamp) || - record.used_at > existingTimestamp) + if (!latestIpTimestamps.TryGetValue(record.Address, out var existingTimestamp) || + record.Used_at > existingTimestamp) { - latestIpTimestamps[record.address] = record.used_at; + latestIpTimestamps[record.Address] = record.Used_at; currentIpSet.Add(new IpRecord( - record.address, - record.used_at, - string.IsNullOrEmpty(record.name) ? unknownName : record.name + record.Address, + record.Used_at, + string.IsNullOrEmpty(record.Name) ? unknownName : record.Name )); } } @@ -156,13 +158,20 @@ internal class CacheManager: IDisposable await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); IEnumerable updatedBans; + // Get current time from database in local timezone (CURRENT_TIMESTAMP uses session timezone, not UTC) + var currentDatabaseTime = await connection.QueryFirstAsync("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? allIds = null; if (CS2_SimpleAdmin.Instance.Config.MultiServerMode) { - updatedBans = (await connection.QueryAsync( + // 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( """ 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(); + 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("SELECT id FROM sa_bans")).ToHashSet(); } updatedBans = updatedList; + + // Update last check time to current database time + _lastDatabaseTime = currentDatabaseTime; } else { - updatedBans = (await connection.QueryAsync( + // 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( """ 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(); + 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( @@ -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 @@ -225,34 +272,34 @@ internal class CacheManager: IDisposable if (string.IsNullOrWhiteSpace(ban.PlayerIp) || !IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) || - !_ipIndex.TryGetValue(ipUInt, out var ipBans)) continue; - { - ipBans.RemoveAll(b => b.Id == id); - if (ipBans.Count == 0) - _ipIndex.TryRemove(ipUInt, out _); - } + !_ipIndex.TryGetValue(ipUInt, out var ipBans)) + continue; + + ipBans.RemoveAll(b => b.Id == id); + if (ipBans.Count == 0) + _ipIndex.TryRemove(ipUInt, out _); } } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) { - var ipHistory = (await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>( + var ipHistory = (await connection.QueryAsync( "SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300", new { lastUpdate = _lastUpdateTime })); - foreach (var group in ipHistory.AsValueEnumerable().GroupBy(x => x.steamid)) + foreach (var group in ipHistory.AsValueEnumerable().GroupBy(x => x.Steamid)) { var ipSet = new HashSet( group - .GroupBy(x => x.address) + .GroupBy(x => x.Address) .Select(g => { - var latest = g.MaxBy(x => x.used_at); + var latest = g.MaxBy(x => x.Used_at); return new IpRecord( g.Key, - latest.used_at, - !string.IsNullOrEmpty(latest.name) - ? latest.name + latest.Used_at, + !string.IsNullOrEmpty(latest.Name) + ? latest.Name : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown" ); }), @@ -260,7 +307,7 @@ internal class CacheManager: IDisposable ); _playerIpsCache.AddOrUpdate( - group.Key, + (ulong)group.Key, _ => ipSet, (_, existingSet) => { @@ -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,32 +486,41 @@ 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) || !IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt) || _cachedIgnoredIps.Contains(ipUInt) || !_ipIndex.TryGetValue(ipUInt, out var ipRecords)) return false; - + 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)) { _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); } - + return true; } @@ -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)) + // 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; } diff --git a/CS2-SimpleAdmin/Managers/PermissionManager.cs b/CS2-SimpleAdmin/Managers/PermissionManager.cs index c9f2b41..5826e74 100644 --- a/CS2-SimpleAdmin/Managers/PermissionManager.cs +++ b/CS2-SimpleAdmin/Managers/PermissionManager.cs @@ -1,4 +1,4 @@ -using CounterStrikeSharp.API; +using CounterStrikeSharp.API; using CounterStrikeSharp.API.Modules.Entities; using Dapper; using Microsoft.Extensions.Logging; @@ -12,54 +12,8 @@ namespace CS2_SimpleAdmin.Managers; public class PermissionManager(IDatabaseProvider? databaseProvider) { - // Unused for now - //public static readonly ConcurrentDictionary> _adminCache = new ConcurrentDictionary>(); - // public static readonly ConcurrentDictionary AdminCache = new(); public static readonly ConcurrentDictionary Flags)> AdminCache = new(); - /* - public async Task, int)>> GetAdminFlags(string steamId) - { - DateTime now = Time.ActualDateTime(); - - await using MySqlConnection connection = await _database.GetConnectionAsync(); - - string sql = "SELECT flags, immunity, ends FROM sa_admins WHERE player_steamid = @PlayerSteamID AND (ends IS NULL OR ends > @CurrentTime) AND (server_id IS NULL OR server_id = @serverid)"; - List? activeFlags = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, CurrentTime = now, serverid = CS2_SimpleAdmin.ServerId }))?.ToList(); - - if (activeFlags == null) - { - return new List<(List, int)>(); - } - - List<(List, int)> filteredFlagsWithImmunity = []; - - foreach (dynamic flags in activeFlags) - { - if (flags is not IDictionary flagsDict) - { - continue; - } - - if (!flagsDict.TryGetValue("flags", out var flagsValueObj) || !flagsDict.TryGetValue("immunity", out var immunityValueObj)) - { - continue; - } - - if (!(flagsValueObj is string flagsValue) || !int.TryParse(immunityValueObj.ToString(), out var immunityValue)) - { - continue; - } - - //Console.WriteLine($"Flags: {flagsValue}, Immunity: {immunityValue}"); - - filteredFlagsWithImmunity.Add((flagsValue.Split(',').ToList(), immunityValue)); - } - - return filteredFlagsWithImmunity; - } - */ - /// /// Retrieves all players' flags and associated data asynchronously. /// @@ -100,7 +54,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) string playerName = g.Key.playerName as string ?? string.Empty; - // tutaj zakładamy, że Dapper zwraca już string (nie dynamic) + // Dapper returns string here, not dynamic var flags = g.Select(r => r.flag as string ?? string.Empty) .Distinct() .ToList(); @@ -118,72 +72,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) } } - - /* - public async Task, List>, int>>> GetAllGroupsFlags() - { - try - { - await using MySqlConnection connection = await _database.GetConnectionAsync(); - - string sql = "SELECT group_id FROM sa_groups_servers WHERE server_id = @serverid"; - var groupIds = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); - - sql = @" - SELECT g.group_id, f.flag - FROM sa_groups_flags f - JOIN sa_groups_servers g ON f.group_id = g.group_id - WHERE g.server_id = @serverid"; - - var groupFlagData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); - - if (groupIds.Count == 0 || groupFlagData.Count == 0) - { - return []; - } - - var groupInfoDictionary = new Dictionary, List>, int>>(); - - foreach (var groupId in groupIds) - { - groupInfoDictionary[groupId] = new Tuple, List>, int>([], [], 0); - } - - foreach (var row in groupFlagData) - { - var groupId = (int)row.group_id; - var flag = (string)row.flag; - - groupInfoDictionary[groupId].Item1.Add(flag); - } - - sql = @" - SELECT a.group_id, a.player_steamid, a.ends, g.immunity, g.name - FROM sa_admins a - JOIN sa_groups g ON a.group_id = g.id - WHERE a.group_id IN @groupIds"; - - var playerData = (await connection.QueryAsync(sql, new { groupIds })).ToList(); - - foreach (var row in playerData) - { - var groupId = (int)row.group_id; - var playerSteamid = (string)row.player_steamid; - var ends = row.ends as DateTime?; - var immunity = (int)row.immunity; - - groupInfoDictionary[groupId].Item2.Add(new Tuple(playerSteamid, ends)); - groupInfoDictionary[groupId] = new Tuple, List>, int>(groupInfoDictionary[groupId].Item1, groupInfoDictionary[groupId].Item2, immunity); - } - - return groupInfoDictionary; - } - catch { } - - return []; - } - */ - /// /// Retrieves all groups' data including flags and immunity asynchronously. /// @@ -192,13 +80,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) { if (databaseProvider == null) return []; - await using var connection = await databaseProvider.CreateConnectionAsync(); - ; try { - // var sql = "SELECT group_id FROM sa_groups_servers WHERE (server_id = @serverid OR server_id IS NULL)"; - // var groupDataSql = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); - + await using var connection = await databaseProvider.CreateConnectionAsync(); var sql = databaseProvider.GetGroupsQuery(); var groupData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); if (groupData.Count == 0) @@ -213,11 +97,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) var flag = (string)row.flag; var immunity = (int)row.immunity; - // Check if the group name already exists in the dictionary if (!groupInfoDictionary.TryGetValue(groupName, out (List, int) value)) { value = ([], immunity); - // If it doesn't exist, add a new entry with an empty list of flags and immunity groupInfoDictionary[groupName] = value; } @@ -238,7 +120,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) /// Creates a JSON file containing groups data asynchronously. /// [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - public async Task CrateGroupsJsonFile() + public async Task CreateGroupsJsonFile() { var groupsData = await GetAllGroupsData(); var jsonData = new Dictionary(); @@ -257,7 +139,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) var options = new JsonSerializerOptions { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var json = JsonSerializer.Serialize(jsonData, options); @@ -265,66 +147,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) await File.WriteAllTextAsync(filePath, json); } - /* - public async Task GiveAllGroupsFlags() - { - Dictionary, List>, int>> groupFlags = await GetAllGroupsFlags(); - - foreach (var kvp in groupFlags) - { - var flags = kvp.Value.Item1; - var players = kvp.Value.Item2; - int immunity = kvp.Value.Item3; - - foreach (var playerTuple in players) - { - var steamIdStr = playerTuple.Item1; - var ends = playerTuple.Item2; - - if (!string.IsNullOrEmpty(steamIdStr) && SteamID.TryParse(steamIdStr, out var steamId) && steamId != null) - { - if (!_adminCache.ContainsKey(steamId)) - { - _adminCache.TryAdd(steamId, ends); - } - - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - // Often need to call 2 times - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - } - } - } - } - */ - /* - public async Task GiveAllFlags() - { - List<(string, string, List, int, DateTime?)> allPlayers = await GetAllPlayersFlags(); - - foreach (var record in allPlayers) - { - string steamIdStr = record.Item1; - List flags = record.Item2; - int immunity = record.Item3; - - DateTime? ends = record.Item4; - - if (!string.IsNullOrEmpty(steamIdStr) && SteamID.TryParse(steamIdStr, out var steamId) && steamId != null) - { - if (!_adminCache.ContainsKey(steamId)) - { - _adminCache.TryAdd(steamId, ends); - //_adminCacheTimestamps.Add(steamId, ends); - } - - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - // Often need to call 2 times - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - } - } - } - */ - /// /// Creates a JSON file containing admins data asynchronously. /// @@ -336,25 +158,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) .Where(player => SteamID.TryParse(player.identity.ToString(), out _)) .ToList(); - // foreach (var player in allPlayers) - // { - // var (steamId, name, flags, immunity, ends) = player; - // - // Console.WriteLine($"Player SteamID: {steamId}"); - // Console.WriteLine($"Player Name: {name}"); - // Console.WriteLine($"Flags: {string.Join(", ", flags)}"); - // Console.WriteLine($"Immunity: {immunity}"); - // Console.WriteLine($"Ends: {(ends.HasValue ? ends.Value.ToString("yyyy-MM-dd HH:mm:ss") : "Never")}"); - // Console.WriteLine(); - // } - var jsonData = validPlayers - .GroupBy(player => player.name) // Group by player name + .GroupBy(player => player.name) .ToDictionary( - group => group.Key, // Use the player name as key + group => group.Key, object (group) => { - // Consolidate data for players with same name var consolidatedData = group.Aggregate( new { @@ -365,16 +174,13 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) }, (acc, player) => { - // Merge identities if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString())) { acc = acc with { identity = player.identity.ToString() }; } - // Combine immunities by maximum value acc = acc with { immunity = Math.Max(acc.immunity, player.immunity) }; - // Combine flags and groups acc = acc with { flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(), @@ -383,12 +189,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) return acc; }); - + Server.NextWorldUpdate(() => { var keysToRemove = new List(); - foreach (var steamId in AdminCache.Keys.ToList()) + foreach (var steamId in AdminCache.Keys.ToList()) { var data = AdminManager.GetPlayerAdminData(steamId); if (data != null) @@ -422,40 +228,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) } }); - // Server.NextFrameAsync(() => - // { - // for (var index = 0; index < AdminCache.Keys.ToList().Count; index++) - // { - // var steamId = AdminCache.Keys.ToList()[index]; - // - // var data = AdminManager.GetPlayerAdminData(steamId); - // if (data != null) - // { - // AdminManager.RemovePlayerPermissions(steamId, AdminCache[steamId].Flags.ToArray()); - // AdminManager.RemovePlayerFromGroup(steamId, true, AdminCache[steamId].Flags.ToArray()); - // } - // - // if (!AdminCache.TryRemove(steamId, out _)) continue; - // - // if (data == null) continue; - // if (data.Flags.ToList().Count != 0 && data.Groups.ToList().Count != 0) - // continue; - // - // AdminManager.ClearPlayerPermissions(steamId); - // AdminManager.RemovePlayerAdminData(steamId); - // } - // - // foreach (var player in group) - // { - // SteamID.TryParse(player.identity, out var steamId); - // if (steamId == null) continue; - // AdminCache.TryAdd(steamId, (player.ends, player.flags)); - // } - // }); - return consolidatedData; }); - + var options = new JsonSerializerOptions { WriteIndented = true, @@ -464,10 +239,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) var json = JsonSerializer.Serialize(jsonData, options); var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json"); - await File.WriteAllTextAsync(filePath, json); - - //await File.WriteAllTextAsync(CS2_SimpleAdmin.Instance.ModuleDirectory + "/data/admins.json", json); } /// @@ -480,8 +252,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (databaseProvider == null) return; if (string.IsNullOrEmpty(playerSteamId)) return; - //_adminCache.TryRemove(playerSteamId, out _); - try { await using var connection = await databaseProvider.CreateConnectionAsync(); @@ -510,18 +280,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return; var now = Time.ActualDateTime(); - DateTime? futureTime; - - if (time != 0) - futureTime = now.AddMinutes(time); - else - futureTime = null; + DateTime? futureTime = time != 0 ? now.AddMinutes(time) : null; try { await using var connection = await databaseProvider.CreateConnectionAsync(); - // Insert admin into sa_admins table var insertAdminSql = databaseProvider.GetAddAdminQuery(); var adminId = await connection.ExecuteScalarAsync(insertAdminSql, new { @@ -533,28 +297,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId }); - // Insert flags into sa_admins_flags table foreach (var flag in flagsList) { - // if (flag.StartsWith($"#")) - // { - // // const string sql = "SELECT id FROM `sa_groups` WHERE name = @groupName"; - // // var groupId = await connection.QuerySingleOrDefaultAsync(sql, new { groupName = flag }); - // - // var sql = databaseProvider.GetGroupIdByNameQuery(); - // var groupId = await connection.QuerySingleOrDefaultAsync(sql, new { groupName = flag, CS2_SimpleAdmin.ServerId }); - // - // if (groupId != null) - // { - // var updateAdminGroup = "UPDATE `sa_admins` SET group_id = @groupId WHERE id = @adminId"; - // await connection.ExecuteAsync(updateAdminGroup, new - // { - // groupId, - // adminId - // }); - // } - // } - var insertFlagsSql = databaseProvider.GetAddAdminFlagsQuery(); await connection.ExecuteAsync(insertFlagsSql, new { @@ -587,10 +331,10 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return; - await using var connection = await databaseProvider.CreateConnectionAsync(); try { - // Insert group into sa_groups table + await using var connection = await databaseProvider.CreateConnectionAsync(); + var insertGroup = databaseProvider.GetAddGroupQuery(); var groupId = await connection.ExecuteScalarAsync(insertGroup, new { @@ -598,11 +342,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) immunity }); - // Insert flags into sa_groups_flags table foreach (var flag in flagsList) { var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery(); - await connection.ExecuteAsync(insertFlagsSql, new { groupId, @@ -616,7 +358,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) { CS2_SimpleAdmin.Instance.ReloadAdmins(null); }); - } catch (Exception ex) { @@ -634,9 +375,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (string.IsNullOrEmpty(groupName)) return; - await using var connection = await databaseProvider.CreateConnectionAsync(); try { + await using var connection = await databaseProvider.CreateConnectionAsync(); var sql = databaseProvider.GetDeleteGroupQuery(); await connection.ExecuteAsync(sql, new { groupName }); } @@ -665,4 +406,4 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) CS2_SimpleAdmin._logger?.LogCritical("Unable to remove expired admins"); } } -} \ No newline at end of file +} diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index aff7930..1736112 100644 --- a/CS2-SimpleAdmin/Managers/PlayerManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerManager.cs @@ -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; /// @@ -52,81 +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); - - // MySQL: INSERT ... ON DUPLICATE KEY UPDATE pattern - const string upsertQuery = """ - INSERT INTO `sa_players_ips` (steamid, name, address, used_at) - VALUES (@SteamID, @playerName, @IPAddress, CURRENT_TIMESTAMP) - ON DUPLICATE KEY UPDATE - used_at = CURRENT_TIMESTAMP, - name = @playerName; - """; - - 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() ?? []; @@ -213,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))) { @@ -260,12 +219,45 @@ internal class PlayerManager _loadPlayerSemaphore.Release(); } }); + if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name)) { player.Rename(name); } } + /// + /// 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. @@ -283,6 +275,16 @@ internal class PlayerManager /// 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 @@ -293,9 +295,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) @@ -370,7 +369,12 @@ internal class PlayerManager foreach (var player in bannedPlayers) { if (!player.UserId.HasValue) continue; - await Server.NextWorldUpdateAsync(() => Helper.KickPlayer((int)player.UserId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED)); + await Server.NextWorldUpdateAsync(() => + { + if (Helper.GetPlayerFromSteamid64(player.SteamID) != null) + Helper.KickPlayer((int)player.UserId, + NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); + }); } } diff --git a/CS2-SimpleAdmin/Managers/ServerManager.cs b/CS2-SimpleAdmin/Managers/ServerManager.cs index e2401bc..ec80aaa 100644 --- a/CS2-SimpleAdmin/Managers/ServerManager.cs +++ b/CS2-SimpleAdmin/Managers/ServerManager.cs @@ -31,7 +31,7 @@ public class ServerManager /// 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) { diff --git a/CS2-SimpleAdmin/Managers/WarnManager.cs b/CS2-SimpleAdmin/Managers/WarnManager.cs index 0f58b84..7db3d1d 100644 --- a/CS2-SimpleAdmin/Managers/WarnManager.cs +++ b/CS2-SimpleAdmin/Managers/WarnManager.cs @@ -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, diff --git a/CS2-SimpleAdmin/Menus/BasicMenu.cs b/CS2-SimpleAdmin/Menus/BasicMenu.cs index b0fba78..1234079 100644 --- a/CS2-SimpleAdmin/Menus/BasicMenu.cs +++ b/CS2-SimpleAdmin/Menus/BasicMenu.cs @@ -1,5 +1,6 @@ using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Translations; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities.Constants; @@ -12,32 +13,33 @@ public abstract class BasicMenu { /// /// Initializes all menus in the system by registering them with the MenuManager. + /// Register with translation keys instead of static names - translation happens per-player. /// public static void Initialize() { var manager = MenuManager.Instance; - // Players category menus - manager.RegisterMenu("players", "slap", "Slap Player", CreateSlapMenu, "@css/slay"); - manager.RegisterMenu("players", "slay", "Slay Player", CreateSlayMenu, "@css/slay"); - manager.RegisterMenu("players", "kick", "Kick Player", CreateKickMenu, "@css/kick"); - manager.RegisterMenu("players", "warn", "Warn Player", CreateWarnMenu, "@css/kick"); - manager.RegisterMenu("players", "ban", "Ban Player", CreateBanMenu, "@css/ban"); - manager.RegisterMenu("players", "gag", "Gag Player", CreateGagMenu, "@css/chat"); - manager.RegisterMenu("players", "mute", "Mute Player", CreateMuteMenu, "@css/chat"); - manager.RegisterMenu("players", "silence", "Silence Player", CreateSilenceMenu, "@css/chat"); - manager.RegisterMenu("players", "team", "Force Team", CreateForceTeamMenu, "@css/kick"); + // Players category menus - using translation keys + manager.RegisterMenu("players", "slap", "sa_slap", CreateSlapMenu, "@css/slay"); + manager.RegisterMenu("players", "slay", "sa_slay", CreateSlayMenu, "@css/slay"); + manager.RegisterMenu("players", "kick", "sa_kick", CreateKickMenu, "@css/kick"); + manager.RegisterMenu("players", "warn", "sa_warn", CreateWarnMenu, "@css/kick"); + manager.RegisterMenu("players", "ban", "sa_ban", CreateBanMenu, "@css/ban"); + manager.RegisterMenu("players", "gag", "sa_gag", CreateGagMenu, "@css/chat"); + 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"); - // Server category menus - manager.RegisterMenu("server", "plugins", "Manage Plugins", CreatePluginsMenu, "@css/root"); - manager.RegisterMenu("server", "changemap", "Change Map", CreateChangeMapMenu, "@css/changemap"); - manager.RegisterMenu("server", "restart", "Restart Game", CreateRestartGameMenu, "@css/generic"); - manager.RegisterMenu("server", "custom", "Custom Commands", CreateCustomCommandsMenu, "@css/generic"); + // Server category menus - using translation keys + manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root"); + manager.RegisterMenu("server", "changemap", "sa_changemap", CreateChangeMapMenu, "@css/changemap"); + manager.RegisterMenu("server", "restart", "sa_restart_game", CreateRestartGameMenu, "@css/generic"); + manager.RegisterMenu("server", "custom", "sa_menu_custom_commands", CreateCustomCommandsMenu, "@css/generic"); - // Admin category menus - manager.RegisterMenu("admin", "add", "Add Admin", CreateAddAdminMenu, "@css/root"); - manager.RegisterMenu("admin", "remove", "Remove Admin", CreateRemoveAdminMenu, "@css/root"); - manager.RegisterMenu("admin", "reload", "Reload Admins", CreateReloadAdminsMenu, "@css/root"); + // Admin category menus - using translation keys + manager.RegisterMenu("admin", "add", "sa_admin_add", CreateAddAdminMenu, "@css/root"); + manager.RegisterMenu("admin", "remove", "sa_admin_remove", CreateRemoveAdminMenu, "@css/root"); + manager.RegisterMenu("admin", "reload", "sa_admin_reload", CreateReloadAdminsMenu, "@css/root"); } /// @@ -49,14 +51,15 @@ public abstract class BasicMenu private static MenuBuilder CreateSlapMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var slapMenu = new MenuBuilder(localizer?["sa_slap"] ?? "Slap Player"); + var slapMenu = new MenuBuilder("sa_slap", admin, localizer); var players = Helper.GetValidPlayers().Where(admin.CanTarget); - + foreach (var player in players) { - var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName; - slapMenu.AddSubMenu(playerName, () => CreateSlapDamageMenu(admin, player)); + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + slapMenu.AddSubMenu(playerName, () => CreateSlapDamageMenu(admin, capturedPlayer)); } return slapMenu.WithBackButton(); @@ -70,18 +73,25 @@ public abstract class BasicMenu /// A MenuBuilder instance for the slap damage menu. private static MenuBuilder CreateSlapDamageMenu(CCSPlayerController admin, CCSPlayerController target) { - var slapDamageMenu = new MenuBuilder($"Slap: {target.PlayerName}"); + var localizer = CS2_SimpleAdmin._localizer; + string localizedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + localizedTitle = $"{localizer?["sa_slap"] ?? "Slap"}: {target.PlayerName}"; + } + + var slapDamageMenu = new MenuBuilder(localizedTitle); var damages = new[] { 0, 1, 5, 10, 50, 100 }; foreach (var damage in damages) { - slapDamageMenu.AddOption($"{damage} HP", _ => + slapDamageMenu.AddOption($"{damage} HP", currentAdmin => { if (target.IsValid) { - CS2_SimpleAdmin.Slap(admin, target, damage); - // Keep menu open for consecutive slaps - CreateSlapDamageMenu(admin, target).OpenMenu(admin); + CS2_SimpleAdmin.Slap(currentAdmin, target, damage); + // Reopen the same menu (not create new one) to keep back button working + slapDamageMenu.OpenMenu(currentAdmin); } }); } @@ -97,18 +107,19 @@ public abstract class BasicMenu private static MenuBuilder CreateSlayMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var slayMenu = new MenuBuilder(localizer?["sa_slay"] ?? "Slay Player"); + var slayMenu = new MenuBuilder("sa_slay", admin, localizer); var players = Helper.GetValidPlayers().Where(admin.CanTarget); - + foreach (var player in players) { - var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName; + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; slayMenu.AddOption(playerName, _ => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Slay(admin, player); + CS2_SimpleAdmin.Slay(admin, capturedPlayer); } }); } @@ -124,19 +135,20 @@ public abstract class BasicMenu private static MenuBuilder CreateKickMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var kickMenu = new MenuBuilder(localizer?["sa_kick"] ?? "Kick Player"); + var kickMenu = new MenuBuilder("sa_kick", 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; - kickMenu.AddSubMenu(playerName, () => CreateReasonMenu(admin, player, "Kick", PenaltyType.Kick, + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + kickMenu.AddSubMenu(playerName, () => CreateReasonMenu(admin, capturedPlayer, "Kick", PenaltyType.Kick, (_, _, reason) => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.Kick(admin, player, reason, admin.PlayerName); + CS2_SimpleAdmin.Instance.Kick(admin, capturedPlayer, reason, admin.PlayerName); } })); } @@ -152,20 +164,21 @@ public abstract class BasicMenu private static MenuBuilder CreateWarnMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var warnMenu = new MenuBuilder(localizer?["sa_warn"] ?? "Warn Player"); + var warnMenu = new MenuBuilder("sa_warn", 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; - warnMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Warn", - (_, _, duration) => CreateReasonMenu(admin, player, "Warn", PenaltyType.Warn, + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + warnMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Warn", + (_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Warn", PenaltyType.Warn, (_, _, reason) => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.Warn(admin, player, duration, reason, admin.PlayerName); + CS2_SimpleAdmin.Instance.Warn(admin, capturedPlayer, duration, reason, admin.PlayerName); } }))); } @@ -181,20 +194,21 @@ public abstract class BasicMenu private static MenuBuilder CreateBanMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var banMenu = new MenuBuilder(localizer?["sa_ban"] ?? "Ban Player"); + var banMenu = new MenuBuilder("sa_ban", 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; - banMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Ban", - (_, _, duration) => CreateReasonMenu(admin, player, "Ban", PenaltyType.Ban, + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + banMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Ban", + (_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Ban", PenaltyType.Ban, (_, _, reason) => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.Ban(admin, player, duration, reason, admin.PlayerName); + CS2_SimpleAdmin.Instance.Ban(admin, capturedPlayer, duration, reason, admin.PlayerName); } }))); } @@ -210,20 +224,21 @@ public abstract class BasicMenu private static MenuBuilder CreateGagMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var gagMenu = new MenuBuilder(localizer?["sa_gag"] ?? "Gag Player"); + var gagMenu = new MenuBuilder("sa_gag", 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; - gagMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Gag", - (_, _, duration) => CreateReasonMenu(admin, player, "Gag", PenaltyType.Gag, + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + gagMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Gag", + (_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Gag", PenaltyType.Gag, (_, _, reason) => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.Gag(admin, player, duration, reason); + CS2_SimpleAdmin.Instance.Gag(admin, capturedPlayer, duration, reason); } }))); } @@ -239,20 +254,21 @@ public abstract class BasicMenu private static MenuBuilder CreateMuteMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var muteMenu = new MenuBuilder(localizer?["sa_mute"] ?? "Mute Player"); + var muteMenu = new MenuBuilder("sa_mute", 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; - muteMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Mute", - (_, _, duration) => CreateReasonMenu(admin, player, "Mute", PenaltyType.Mute, + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + muteMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Mute", + (_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Mute", PenaltyType.Mute, (_, _, reason) => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.Mute(admin, player, duration, reason); + CS2_SimpleAdmin.Instance.Mute(admin, capturedPlayer, duration, reason); } }))); } @@ -268,20 +284,21 @@ public abstract class BasicMenu private static MenuBuilder CreateSilenceMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var silenceMenu = new MenuBuilder(localizer?["sa_silence"] ?? "Silence Player"); + var silenceMenu = new MenuBuilder("sa_silence", 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; - silenceMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Silence", - (_, _, duration) => CreateReasonMenu(admin, player, "Silence", PenaltyType.Silence, + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + silenceMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Silence", + (_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Silence", PenaltyType.Silence, (_, _, reason) => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.Silence(admin, player, duration, reason); + CS2_SimpleAdmin.Instance.Silence(admin, capturedPlayer, duration, reason); } }))); } @@ -297,14 +314,15 @@ public abstract class BasicMenu private static MenuBuilder CreateForceTeamMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var teamMenu = new MenuBuilder(localizer?["sa_team_force"] ?? "Force Team"); + var teamMenu = new MenuBuilder("sa_team_force", admin, localizer); var players = Helper.GetValidPlayers().Where(p => admin.CanTarget(p)); - + foreach (var player in players) { - var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName; - teamMenu.AddSubMenu(playerName, () => CreateTeamSelectionMenu(admin, player)); + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + teamMenu.AddSubMenu(playerName, () => CreateTeamSelectionMenu(admin, capturedPlayer)); } return teamMenu.WithBackButton(); @@ -319,14 +337,32 @@ public abstract class BasicMenu private static MenuBuilder CreateTeamSelectionMenu(CCSPlayerController admin, CCSPlayerController target) { var localizer = CS2_SimpleAdmin._localizer; - var teamSelectionMenu = new MenuBuilder($"Force Team: {target.PlayerName}"); + + // Localize title for admin's language + string localizedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + localizedTitle = $"{localizer?["sa_team_force"] ?? "Force Team"}: {target.PlayerName}"; + } + + var teamSelectionMenu = new MenuBuilder(localizedTitle); + + // Localize team options for admin's language + string ctName, tName, swapName, specName; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + ctName = localizer?["sa_team_ct"] ?? "CT"; + tName = localizer?["sa_team_t"] ?? "T"; + swapName = localizer?["sa_team_swap"] ?? "Swap"; + specName = localizer?["sa_team_spec"] ?? "Spec"; + } var teams = new[] { - (localizer?["sa_team_ct"] ?? "CT", "ct", CsTeam.CounterTerrorist), - (localizer?["sa_team_t"] ?? "T", "t", CsTeam.Terrorist), - (localizer?["sa_team_swap"] ?? "Swap", "swap", CsTeam.Spectator), - (localizer?["sa_team_spec"] ?? "Spec", "spec", CsTeam.Spectator) + (ctName, "ct", CsTeam.CounterTerrorist), + (tName, "t", CsTeam.Terrorist), + (swapName, "swap", CsTeam.Spectator), + (specName, "spec", CsTeam.Spectator) }; foreach (var (name, teamName, teamNum) in teams) @@ -351,7 +387,7 @@ public abstract class BasicMenu private static MenuBuilder CreatePluginsMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var pluginsMenu = new MenuBuilder(localizer?["sa_menu_pluginsmanager_title"] ?? "Manage Plugins"); + var pluginsMenu = new MenuBuilder("sa_menu_pluginsmanager_title", admin, localizer); pluginsMenu.AddOption("Open Plugins Manager", _ => { @@ -369,7 +405,7 @@ public abstract class BasicMenu private static MenuBuilder CreateChangeMapMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var mapMenu = new MenuBuilder(localizer?["sa_changemap"] ?? "Change Map"); + var mapMenu = new MenuBuilder("sa_changemap", admin, localizer); // Add default maps var maps = CS2_SimpleAdmin.Instance.Config.DefaultMaps; @@ -402,7 +438,7 @@ public abstract class BasicMenu private static MenuBuilder CreateRestartGameMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var restartMenu = new MenuBuilder(localizer?["sa_restart_game"] ?? "Restart Game"); + var restartMenu = new MenuBuilder("sa_restart_game", admin, localizer); restartMenu.AddOption("Restart Round", _ => { @@ -420,7 +456,7 @@ public abstract class BasicMenu private static MenuBuilder CreateCustomCommandsMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var customMenu = new MenuBuilder(localizer?["sa_menu_custom_commands"] ?? "Custom Commands"); + var customMenu = new MenuBuilder("sa_menu_custom_commands", admin, localizer); var customCommands = CS2_SimpleAdmin.Instance.Config.CustomServerCommands; @@ -455,14 +491,15 @@ public abstract class BasicMenu private static MenuBuilder CreateAddAdminMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var addAdminMenu = new MenuBuilder(localizer?["sa_admin_add"] ?? "Add Admin"); + var addAdminMenu = new MenuBuilder("sa_admin_add", 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; - addAdminMenu.AddSubMenu(playerName, () => CreateAdminFlagsMenu(admin, player)); + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; + addAdminMenu.AddSubMenu(playerName, () => CreateAdminFlagsMenu(admin, capturedPlayer)); } return addAdminMenu.WithBackButton(); @@ -476,7 +513,16 @@ public abstract class BasicMenu /// A MenuBuilder instance for the admin flags menu. private static MenuBuilder CreateAdminFlagsMenu(CCSPlayerController admin, CCSPlayerController target) { - var flagsMenu = new MenuBuilder($"Add Admin: {target.PlayerName}"); + var localizer = CS2_SimpleAdmin._localizer; + + // Localize title for admin's language + string localizedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + localizedTitle = $"{localizer?["sa_admin_add"] ?? "Add Admin"}: {target.PlayerName}"; + } + + var flagsMenu = new MenuBuilder(localizedTitle); foreach (var adminFlag in CS2_SimpleAdmin.Instance.Config.MenuConfigs.AdminFlags) { @@ -501,21 +547,22 @@ public abstract class BasicMenu private static MenuBuilder CreateRemoveAdminMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var removeAdminMenu = new MenuBuilder(localizer?["sa_admin_remove"] ?? "Remove Admin"); + var removeAdminMenu = new MenuBuilder("sa_admin_remove", admin, localizer); - var adminPlayers = Helper.GetValidPlayers().Where(p => - AdminManager.GetPlayerAdminData(p)?.Flags.Count > 0 && - p != admin && + var adminPlayers = Helper.GetValidPlayers().Where(p => + AdminManager.GetPlayerAdminData(p)?.Flags.Count > 0 && + p != admin && admin.CanTarget(p)); - + foreach (var player in adminPlayers) { - var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName; + var capturedPlayer = player; // Capture to local variable to avoid closure issue + var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; removeAdminMenu.AddOption(playerName, _ => { - if (player.IsValid) + if (capturedPlayer.IsValid) { - CS2_SimpleAdmin.Instance.RemoveAdmin(admin, player.SteamID.ToString()); + CS2_SimpleAdmin.Instance.RemoveAdmin(admin, capturedPlayer.SteamID.ToString()); } }); } @@ -531,7 +578,7 @@ public abstract class BasicMenu private static MenuBuilder CreateReloadAdminsMenu(CCSPlayerController admin) { var localizer = CS2_SimpleAdmin._localizer; - var reloadMenu = new MenuBuilder(localizer?["sa_admin_reload"] ?? "Reload Admins"); + var reloadMenu = new MenuBuilder("sa_admin_reload", admin, localizer); reloadMenu.AddOption("Reload Admins", _ => { @@ -546,20 +593,40 @@ public abstract class BasicMenu /// /// The admin player selecting duration. /// The target player for the penalty. - /// The name of the penalty action. - /// Callback action executed when duration is selected. + /// The name of the penalty action (e.g., "Kick", "Ban"). + /// Callback function that returns the next menu when duration is selected. /// A MenuBuilder instance for the duration menu. private static MenuBuilder CreateDurationMenu(CCSPlayerController admin, CCSPlayerController player, string actionName, - Action onSelectAction) + Func onSelectAction) { - var durationMenu = new MenuBuilder($"{actionName} Duration: {player.PlayerName}"); + var localizer = CS2_SimpleAdmin._localizer; + + // Convert action name to translation key (e.g., "Ban" -> "sa_ban") + var actionKey = actionName.ToLower() switch + { + "kick" => "sa_kick", + "ban" => "sa_ban", + "warn" => "sa_warn", + "gag" => "sa_gag", + "mute" => "sa_mute", + "silence" => "sa_silence", + _ => actionName + }; + + // Localize title for admin's language + string localizedAction, durationText; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + localizedAction = localizer?[actionKey] ?? actionName; + durationText = localizer?["sa_duration"] ?? "Duration"; + } + + var durationMenu = new MenuBuilder($"{localizedAction} {durationText}: {player.PlayerName}"); foreach (var durationItem in CS2_SimpleAdmin.Instance.Config.MenuConfigs.Durations) { - durationMenu.AddOption(durationItem.Name, _ => - { - onSelectAction(admin, player, durationItem.Duration); - }); + var capturedDuration = durationItem.Duration; // Capture to avoid closure issue + durationMenu.AddSubMenu(durationItem.Name, () => onSelectAction(admin, player, capturedDuration)); } return durationMenu.WithBackButton(); @@ -570,14 +637,36 @@ public abstract class BasicMenu /// /// The admin player selecting reason. /// The target player for the penalty. - /// The name of the penalty action. + /// The name of the penalty action (e.g., "Kick", "Ban"). /// The type of penalty to determine which reason list to use. /// Callback action executed when reason is selected. /// A MenuBuilder instance for the reason menu. private static MenuBuilder CreateReasonMenu(CCSPlayerController admin, CCSPlayerController player, string actionName, PenaltyType penaltyType, Action onSelectAction) { - var reasonMenu = new MenuBuilder($"{actionName} Reason: {player.PlayerName}"); + var localizer = CS2_SimpleAdmin._localizer; + + // Convert action name to translation key + var actionKey = actionName.ToLower() switch + { + "kick" => "sa_kick", + "ban" => "sa_ban", + "warn" => "sa_warn", + "gag" => "sa_gag", + "mute" => "sa_mute", + "silence" => "sa_silence", + _ => actionName + }; + + // Localize title for admin's language + string localizedAction, reasonText; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + localizedAction = localizer?[actionKey] ?? actionName; + reasonText = localizer?["sa_reason"] ?? "Reason"; + } + + var reasonMenu = new MenuBuilder($"{localizedAction} {reasonText}: {player.PlayerName}"); var reasons = penaltyType switch { diff --git a/CS2-SimpleAdmin/Menus/DurationMenu.cs b/CS2-SimpleAdmin/Menus/DurationMenu.cs index 20a0acc..cfe8ea6 100644 --- a/CS2-SimpleAdmin/Menus/DurationMenu.cs +++ b/CS2-SimpleAdmin/Menus/DurationMenu.cs @@ -8,12 +8,28 @@ public static class DurationMenu public static void OpenMenu(CCSPlayerController admin, string menuName, CCSPlayerController player, Action onSelectAction) { var menu = AdminMenu.CreateMenu(menuName); - foreach (var durationItem in CS2_SimpleAdmin.Instance.Config.MenuConfigs.Durations) + if (menu == null) + return; + + var durations = CS2_SimpleAdmin.Instance.Config.MenuConfigs.Durations; + + // Capture admin and player to avoid closure issues + var capturedAdmin = admin; + var capturedPlayer = player; + var capturedAction = onSelectAction; + + foreach (var durationItem in durations) { - menu?.AddMenuOption(durationItem.Name, (_, _) => { onSelectAction(admin, player, durationItem.Duration); }); + var duration = durationItem.Duration; // Capture in local variable + var name = durationItem.Name; + + menu.AddMenuOption(name, (controller, option) => + { + capturedAction(capturedAdmin, capturedPlayer, duration); + }); } - if (menu != null) AdminMenu.OpenMenu(admin, menu); + AdminMenu.OpenMenu(admin, menu); } public static void OpenMenu(CCSPlayerController admin, string menuName, DisconnectedPlayer player, Action onSelectAction) diff --git a/CS2-SimpleAdmin/Menus/ManagePlayersMenu.cs b/CS2-SimpleAdmin/Menus/ManagePlayersMenu.cs index e31186a..c045ddb 100644 --- a/CS2-SimpleAdmin/Menus/ManagePlayersMenu.cs +++ b/CS2-SimpleAdmin/Menus/ManagePlayersMenu.cs @@ -49,25 +49,27 @@ public static class ManagePlayersMenu if (AdminManager.CommandIsOverriden("css_warn") ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_warn")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/kick")) - options.Add(new ChatMenuOptionData(localizer?["sa_warn"] ?? "Warn", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_warn"] ?? "Warn", (admin, player) => DurationMenu.OpenMenu(admin, $"{localizer?["sa_warn"] ?? "Warn"}: {player.PlayerName}", player, WarnMenu)))); + options.Add(new ChatMenuOptionData(localizer?["sa_warn"] ?? "Warn", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_warn"] ?? "Warn", (a, p) => DurationMenu.OpenMenu(a, $"{localizer?["sa_warn"] ?? "Warn"}: {p.PlayerName}", p, WarnMenu)))); if (hasBan) - options.Add(new ChatMenuOptionData(localizer?["sa_ban"] ?? "Ban", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_ban"] ?? "Ban", (admin, player) => DurationMenu.OpenMenu(admin, $"{localizer?["sa_ban"] ?? "Ban"}: {player.PlayerName}", player, BanMenu)))); + options.Add(new ChatMenuOptionData(localizer?["sa_ban"] ?? "Ban", () => + PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_ban"] ?? "Ban", (a, p) => + DurationMenu.OpenMenu(a, $"{localizer?["sa_ban"] ?? "Ban"}: {p.PlayerName}", p, BanMenu)))); if (hasChat) { if (AdminManager.CommandIsOverriden("css_gag") ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_gag")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat")) - options.Add(new ChatMenuOptionData(localizer?["sa_gag"] ?? "Gag", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_gag"] ?? "Gag", (admin, player) => DurationMenu.OpenMenu(admin, $"{localizer?["sa_gag"] ?? "Gag"}: {player.PlayerName}", player, GagMenu)))); + options.Add(new ChatMenuOptionData(localizer?["sa_gag"] ?? "Gag", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_gag"] ?? "Gag", (a, p) => DurationMenu.OpenMenu(a, $"{localizer?["sa_gag"] ?? "Gag"}: {p.PlayerName}", p, GagMenu)))); if (AdminManager.CommandIsOverriden("css_mute") ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_mute")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat")) - options.Add(new ChatMenuOptionData(localizer?["sa_mute"] ?? "Mute", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_mute"] ?? "Mute", (admin, player) => DurationMenu.OpenMenu(admin, $"{localizer?["sa_mute"] ?? "Mute"}: {player.PlayerName}", player, MuteMenu)))); + options.Add(new ChatMenuOptionData(localizer?["sa_mute"] ?? "Mute", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_mute"] ?? "Mute", (a, p) => DurationMenu.OpenMenu(a, $"{localizer?["sa_mute"] ?? "Mute"}: {p.PlayerName}", p, MuteMenu)))); if (AdminManager.CommandIsOverriden("css_silence") ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_silence")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat")) - options.Add(new ChatMenuOptionData(localizer?["sa_silence"] ?? "Silence", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_silence"] ?? "Silence", (admin, player) => DurationMenu.OpenMenu(admin, $"{localizer?["sa_silence"] ?? "Silence"}: {player.PlayerName}", player, SilenceMenu)))); + options.Add(new ChatMenuOptionData(localizer?["sa_silence"] ?? "Silence", () => PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_silence"] ?? "Silence", (a, p) => DurationMenu.OpenMenu(a, $"{localizer?["sa_silence"] ?? "Silence"}: {p.PlayerName}", p, SilenceMenu)))); } if (AdminManager.CommandIsOverriden("css_team") @@ -159,22 +161,9 @@ public static class ManagePlayersMenu { if (player is { IsValid: true }) Ban(admin, player, duration, reason); - + CS2_SimpleAdmin.MenuApi?.CloseMenu(admin); }); - - // var menu = AdminMenu.CreateMenu($"{CS2_SimpleAdmin._localizer?["sa_ban"] ?? "Ban"}: {player?.PlayerName}"); - // - // foreach (var option in CS2_SimpleAdmin.Instance.Config.MenuConfigs.BanReasons) - // { - // menu?.AddMenuOption(option, (_, _) => - // { - // if (player is { IsValid: true }) - // Ban(admin, player, duration, option); - // }); - // } - // - // if (menu != null) AdminMenu.OpenMenu(admin, menu); } private static void Ban(CCSPlayerController admin, CCSPlayerController player, int duration, string reason) diff --git a/CS2-SimpleAdmin/Menus/MenuBuilder.cs b/CS2-SimpleAdmin/Menus/MenuBuilder.cs index 70655c3..1c8ed3c 100644 --- a/CS2-SimpleAdmin/Menus/MenuBuilder.cs +++ b/CS2-SimpleAdmin/Menus/MenuBuilder.cs @@ -1,28 +1,89 @@ using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Translations; +using Microsoft.Extensions.Localization; namespace CS2_SimpleAdmin.Menus; -public class MenuBuilder(string title) +public class MenuBuilder { + private readonly string _title; + private readonly CCSPlayerController? _player; + private readonly IStringLocalizer? _localizer; private readonly List _options = []; private MenuBuilder? _parentMenu; private Action? _backAction; private Action? _resetAction; + /// + /// Constructor for player-localized menu with translation key + /// + public MenuBuilder(string titleKey, CCSPlayerController player, IStringLocalizer? localizer = null) + { + _title = titleKey; + _player = player; + _localizer = localizer ?? CS2_SimpleAdmin._localizer; + } + + /// + /// Constructor for static title (backward compatibility) + /// + public MenuBuilder(string title) + { + _title = title; + _player = null; + _localizer = null; + } + + /// + /// Gets the localized title for the player + /// + private string GetLocalizedTitle() + { + if (_player != null && _localizer != null) + { + using (new WithTemporaryCulture(_player.GetLanguage())) + { + return _localizer[_title]; + } + } + return _title; + } + /// /// Adds a menu option with an action. /// - public MenuBuilder AddOption(string name, Action action, bool disabled = false, string? permission = null) + /// Display name or translation key + /// Action to perform when selected + /// Whether the option is disabled + /// Required permission + /// If true, name is a translation key to be localized + public MenuBuilder AddOption(string name, Action action, bool disabled = false, string? permission = null, bool isTranslationKey = false) { _options.Add(new MenuOption { Name = name, Action = action, Disabled = disabled, - Permission = permission + Permission = permission, + IsTranslationKey = isTranslationKey }); return this; } + /// + /// Gets the localized name for a menu option + /// + private string GetLocalizedOptionName(MenuOption option) + { + if (option.IsTranslationKey && _player != null && _localizer != null) + { + using (new WithTemporaryCulture(_player.GetLanguage())) + { + return _localizer[option.Name]; + } + } + return option.Name; + } + /// /// Adds a menu option that opens a submenu. /// @@ -99,8 +160,11 @@ public class MenuBuilder(string title) { if (!player.IsValid) return; + // Get localized title + var localizedTitle = GetLocalizedTitle(); + // Use MenuManager dependency - var menu = Helper.CreateMenu(title, _backAction); + var menu = Helper.CreateMenu(localizedTitle, _backAction); if (menu == null) return; foreach (var option in _options) @@ -115,7 +179,10 @@ public class MenuBuilder(string title) } } - menu.AddMenuOption(option.Name, (menuPlayer, menuOption) => + // Get localized option name + var localizedName = GetLocalizedOptionName(option); + + menu.AddMenuOption(localizedName, (menuPlayer, menuOption) => { option.Action?.Invoke(menuPlayer); }, option.Disabled); @@ -166,5 +233,6 @@ public class MenuOption public Action? Action { get; set; } public bool Disabled { get; set; } public string? Permission { get; set; } + public bool IsTranslationKey { get; set; } } \ No newline at end of file diff --git a/CS2-SimpleAdmin/Menus/MenuManager.cs b/CS2-SimpleAdmin/Menus/MenuManager.cs index f1aa450..6abf3ea 100644 --- a/CS2-SimpleAdmin/Menus/MenuManager.cs +++ b/CS2-SimpleAdmin/Menus/MenuManager.cs @@ -1,4 +1,5 @@ using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Translations; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Entities; @@ -38,6 +39,26 @@ public class MenuManager }; } + /// + /// Registers a new menu category with per-player localization support for modules. + /// 🆕 NEW: Enables modules to provide localized category names based on each player's css_lang! + /// + /// Unique identifier for the category. + /// Translation key from module's lang files. + /// Required permission to access this category. + /// Module's IStringLocalizer for per-player translation. + public void RegisterCategory(string categoryId, string categoryNameKey, string permission, Microsoft.Extensions.Localization.IStringLocalizer moduleLocalizer) + { + _menuCategories[categoryId] = new MenuCategory + { + Id = categoryId, + Name = categoryNameKey, // Store the key, not translated text + Permission = permission, + MenuFactories = new Dictionary>(), + ModuleLocalizer = moduleLocalizer // Store module's localizer + }; + } + /// /// Registers a menu within a category (API for other plugins). /// @@ -66,6 +87,37 @@ public class MenuManager } } + /// + /// Registers a menu with per-player localization support for modules. + /// 🆕 NEW: Enables modules to provide localized menu names based on each player's css_lang! + /// + /// The category to add this menu to. + /// Unique identifier for the menu. + /// Translation key from module's lang files. + /// Factory function that creates the menu for a player. + /// Required permission to access this menu (optional). + /// Command name for permission override checking (optional). + /// Module's IStringLocalizer for per-player translation. + public void RegisterMenu(string categoryId, string menuId, string menuNameKey, Func menuFactory, string? permission, string? commandName, Microsoft.Extensions.Localization.IStringLocalizer moduleLocalizer) + { + if (!_menuCategories.ContainsKey(categoryId)) + { + RegisterCategory(categoryId, categoryId); // Auto-create category if it doesn't exist + } + + _menuCategories[categoryId].MenuFactories[menuId] = menuFactory; + _menuCategories[categoryId].MenuNames[menuId] = menuNameKey; // Store the key + _menuCategories[categoryId].MenuLocalizers[menuId] = moduleLocalizer; // Store localizer + if (permission != null) + { + _menuCategories[categoryId].MenuPermissions[menuId] = permission; + } + if (commandName != null) + { + _menuCategories[categoryId].MenuCommandNames[menuId] = commandName; + } + } + /// /// Unregisters a menu from a category. /// @@ -88,7 +140,7 @@ public class MenuManager public MenuBuilder CreateMainMenu(CCSPlayerController player) { var localizer = CS2_SimpleAdmin._localizer; - var mainMenu = new MenuBuilder(localizer?["sa_title"] ?? "SimpleAdmin"); + var mainMenu = new MenuBuilder("sa_title", player, localizer); foreach (var category in _menuCategories.Values) { @@ -98,8 +150,23 @@ public class MenuManager if (!AdminManager.PlayerHasPermissions(steamId, category.Permission)) continue; + // Get localized category name for this player + // If category has a module localizer, use it; otherwise use main plugin localizer + string localizedCategoryName; + using (new WithTemporaryCulture(player.GetLanguage())) + { + if (category.ModuleLocalizer != null) + { + localizedCategoryName = category.ModuleLocalizer[category.Name] ?? category.Name; + } + else + { + localizedCategoryName = localizer?[category.Name] ?? category.Name; + } + } + // Pass player to CreateCategoryMenu - mainMenu.AddSubMenu(category.Name, () => CreateCategoryMenu(category, player), + mainMenu.AddSubMenu(localizedCategoryName, () => CreateCategoryMenu(category, player), permission: category.Permission); } @@ -114,7 +181,24 @@ public class MenuManager /// A MenuBuilder instance for the category menu. private MenuBuilder CreateCategoryMenu(MenuCategory category, CCSPlayerController player) { - var categoryMenu = new MenuBuilder(category.Name); + var localizer = CS2_SimpleAdmin._localizer; + + // Get localized category name for this player + // If category has a module localizer, use it; otherwise use main plugin localizer + string localizedCategoryName; + using (new WithTemporaryCulture(player.GetLanguage())) + { + if (category.ModuleLocalizer != null) + { + localizedCategoryName = category.ModuleLocalizer[category.Name] ?? category.Name; + } + else + { + localizedCategoryName = localizer?[category.Name] ?? category.Name; + } + } + + var categoryMenu = new MenuBuilder(localizedCategoryName); foreach (var kvp in category.MenuFactories) { @@ -159,8 +243,30 @@ public class MenuManager continue; } + // Get localized menu name for this player + // If menu has its own localizer, use it; otherwise use category or main plugin localizer + string localizedMenuName; + using (new WithTemporaryCulture(player.GetLanguage())) + { + if (category.MenuLocalizers.TryGetValue(menuId, out var menuLocalizer)) + { + // Menu has its own module localizer + localizedMenuName = menuLocalizer[menuName] ?? menuName; + } + else if (category.ModuleLocalizer != null) + { + // Use category's module localizer + localizedMenuName = category.ModuleLocalizer[menuName] ?? menuName; + } + else + { + // Use main plugin localizer + localizedMenuName = localizer?[menuName] ?? menuName; + } + } + // Call the actual factory with player parameter - categoryMenu.AddSubMenu(menuName, () => menuFactory(player), permission: permission); + categoryMenu.AddSubMenu(localizedMenuName, () => menuFactory(player), permission: permission); } return categoryMenu.WithBackButton(); @@ -190,12 +296,12 @@ public class MenuManager /// public void InitializeDefaultCategories() { - var localizer = CS2_SimpleAdmin._localizer; - - RegisterCategory("players", localizer?["sa_menu_players_manage"] ?? "Manage Players", "@css/generic"); - RegisterCategory("server", localizer?["sa_menu_server_manage"] ?? "Server Management", "@css/generic"); - // RegisterCategory("fun", localizer?["sa_menu_fun_commands"] ?? "Fun Commands", "@css/generic"); - RegisterCategory("admin", localizer?["sa_menu_admins_manage"] ?? "Admin Management", "@css/root"); + // Register categories with translation keys instead of translated names + // The actual translation will happen per-player in CreateMainMenu/CreateCategoryMenu + RegisterCategory("players", "sa_menu_players_manage", "@css/generic"); + RegisterCategory("server", "sa_menu_server_manage", "@css/generic"); + // RegisterCategory("fun", "sa_menu_fun_commands", "@css/generic"); + RegisterCategory("admin", "sa_menu_admins_manage", "@css/root"); } /// @@ -222,4 +328,17 @@ public class MenuCategory public Dictionary MenuNames { get; set; } = []; public Dictionary MenuPermissions { get; set; } = []; public Dictionary MenuCommandNames { get; set; } = []; + + // 🆕 NEW: Support for per-player localization in modules + /// + /// Optional IStringLocalizer from external module for per-player translation of category name. + /// If null, Name is used as-is (for CS2-SimpleAdmin's built-in categories with translation keys). + /// + public Microsoft.Extensions.Localization.IStringLocalizer? ModuleLocalizer { get; set; } + + /// + /// Stores IStringLocalizer for each menu that uses module localization. + /// Key: menuId, Value: module's localizer + /// + public Dictionary MenuLocalizers { get; set; } = []; } diff --git a/CS2-SimpleAdmin/Menus/PlayersMenu.cs b/CS2-SimpleAdmin/Menus/PlayersMenu.cs index e14270a..6d43a20 100644 --- a/CS2-SimpleAdmin/Menus/PlayersMenu.cs +++ b/CS2-SimpleAdmin/Menus/PlayersMenu.cs @@ -41,11 +41,12 @@ public static class PlayersMenu continue; var enabled = admin.CanTarget(player); + var capturedPlayer = player; // Capture in local variable to avoid closure issues if (optionName != null) - menu?.AddMenuOption(optionName, (_, _) => + menu?.AddMenuOption(optionName, (controller, option) => { - if (player != null) onSelectAction.Invoke(admin, player); + if (capturedPlayer != null) onSelectAction.Invoke(admin, capturedPlayer); }, !enabled); } diff --git a/CS2-SimpleAdmin/Models/BanRecord.cs b/CS2-SimpleAdmin/Models/BanRecord.cs index 026ffd6..996e423 100644 --- a/CS2-SimpleAdmin/Models/BanRecord.cs +++ b/CS2-SimpleAdmin/Models/BanRecord.cs @@ -24,7 +24,10 @@ 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; } diff --git a/CS2-SimpleAdmin/Models/IpHistoryRow.cs b/CS2-SimpleAdmin/Models/IpHistoryRow.cs new file mode 100644 index 0000000..5c794a7 --- /dev/null +++ b/CS2-SimpleAdmin/Models/IpHistoryRow.cs @@ -0,0 +1,9 @@ +namespace CS2_SimpleAdmin.Models; + +public record IpHistoryRow +{ + public long Steamid { get; init; } + public string? Name { get; init; } + public uint Address { get; init; } + public DateTime Used_at { get; init; } +} diff --git a/CS2-SimpleAdmin/VERSION b/CS2-SimpleAdmin/VERSION index 114eb98..c7a73ab 100644 --- a/CS2-SimpleAdmin/VERSION +++ b/CS2-SimpleAdmin/VERSION @@ -1 +1 @@ -1.7.8-beta-3 \ No newline at end of file +1.8.2b \ No newline at end of file diff --git a/CS2-SimpleAdmin/Variables.cs b/CS2-SimpleAdmin/Variables.cs index a35cc75..35424a9 100644 --- a/CS2-SimpleAdmin/Variables.cs +++ b/CS2-SimpleAdmin/Variables.cs @@ -40,7 +40,6 @@ public partial class CS2_SimpleAdmin internal static readonly HashSet AdminDisabledJoinComms = []; // Player Management - private static readonly HashSet GodPlayers = []; internal static readonly HashSet SilentPlayers = []; internal static readonly Dictionary RenamedPlayers = []; internal static readonly ConcurrentDictionary PlayersInfo = []; diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index a768d22..f875b82 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -31,7 +31,7 @@ "sa_team_spec": "Spec", "sa_slap": "Slap", - "sa_slay": "slay", + "sa_slay": "Slay", "sa_kick": "Kick", "sa_ban": "Ban", "sa_gag": "Gag", diff --git a/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj b/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj index d1d66f0..bbd5d85 100644 --- a/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj +++ b/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 CS2_SimpleAdminApi enable enable @@ -9,7 +9,7 @@ - + diff --git a/CS2-SimpleAdminApi/ICS2-SimpleAdminApi.cs b/CS2-SimpleAdminApi/ICS2-SimpleAdminApi.cs index eddfde5..65de92a 100644 --- a/CS2-SimpleAdminApi/ICS2-SimpleAdminApi.cs +++ b/CS2-SimpleAdminApi/ICS2-SimpleAdminApi.cs @@ -140,6 +140,16 @@ public interface ICS2_SimpleAdminApi /// void RegisterMenuCategory(string categoryId, string categoryName, string permission = "@css/generic"); + /// + /// Registers a menu category with per-player localization support for modules. + /// 🆕 NEW: Supports per-player localization using module's IStringLocalizer! + /// + /// The category ID (unique identifier). + /// Translation key from module's lang files. + /// Required permission to access this category. + /// Module's IStringLocalizer for per-player translation. + void RegisterMenuCategory(string categoryId, string categoryNameKey, string permission, object moduleLocalizer); + /// /// Registers a menu in a category. /// @@ -163,6 +173,19 @@ public interface ICS2_SimpleAdminApi /// Command name for permission override checking (optional, e.g., "css_god"). void RegisterMenu(string categoryId, string menuId, string menuName, Func menuFactory, string? permission = null, string? commandName = null); + /// + /// Registers a menu with per-player localization support for modules. + /// 🆕 NEW: Supports per-player localization using module's IStringLocalizer! + /// + /// The category to add this menu to. + /// Unique identifier for the menu. + /// Translation key from module's lang files. + /// Factory function that receives player and menu context. + /// Required permission to access this menu (optional). + /// Command name for permission override checking (optional). + /// Module's IStringLocalizer for per-player translation. + void RegisterMenu(string categoryId, string menuId, string menuNameKey, Func menuFactory, string? permission, string? commandName, object moduleLocalizer); + /// /// Unregisters a menu from a category. /// diff --git a/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs b/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs index 9379f33..03b2495 100644 --- a/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs +++ b/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs @@ -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); } } } \ No newline at end of file diff --git a/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdminApi.dll b/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdminApi.dll index b6fa68c..e8e0151 100644 Binary files a/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdminApi.dll and b/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdminApi.dll differ diff --git a/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.cs b/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.cs index d089358..720b4ad 100644 --- a/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.cs +++ b/Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.cs @@ -145,20 +145,38 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin // STEP 1: Register a menu category // This creates a new section in the main admin menu // Permission: @css/generic means all admins can see it + // + // ⚠️ LOCALIZATION OPTIONS: + // + // OPTION A - No translations (hard-coded text): _sharedApi.RegisterMenuCategory( "example", // Category ID (unique identifier) - "Example Features", // Display name in admin menu + "Example Features", // Display name (hard-coded, same for all players) "@css/generic" // Required permission ); + // + // OPTION B - With per-player translations (🆕 NEW!): + // If your module has lang/ folder with translations, use this pattern: + // _sharedApi.RegisterMenuCategory( + // "example", // Category ID + // "example_category_name", // Translation key + // "@css/generic", // Permission + // Localizer! // Module's localizer + // ); + // This will translate the category name per-player based on their css_lang setting! // STEP 2: Register individual menu items in the category // 🆕 NEW: These use MenuContext API - factory receives (admin, context) parameters + // + // ⚠️ LOCALIZATION OPTIONS: + // + // OPTION A - No translations (hard-coded text): // Example 1: Simple menu with options _sharedApi.RegisterMenu( "example", // Category ID "simple_action", // Menu ID (unique within category) - "Simple Actions", // Display name + "Simple Actions", // Display name (hard-coded) CreateSimpleActionMenu, // Factory method "@css/generic" // Required permission ); @@ -167,7 +185,7 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin _sharedApi.RegisterMenu( "example", "player_selection", - "Select Player", + "Select Player", // Display name CreatePlayerSelectionMenu, "@css/kick" // Requires kick permission ); @@ -176,7 +194,7 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin _sharedApi.RegisterMenu( "example", "nested_menu", - "Give Credits", + "Give Credits", // Display name CreateGiveCreditsMenu, "@css/generic" ); @@ -185,12 +203,26 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin _sharedApi.RegisterMenu( "example", "test_command", - "Test Command", + "Test Command", // Display name CreateTestCommandMenu, "@css/root", // Default permission "css_test" // Command name for override checking ); + // OPTION B - With per-player translations (🆕 NEW!): + // If your module has lang/ folder, use this pattern: + // _sharedApi.RegisterMenu( + // "example", // Category ID + // "menu_id", // Menu ID + // "menu_translation_key", // Translation key (NOT translated text!) + // CreateYourMenu, // Factory method + // "@css/generic", // Permission + // "css_command", // Command name (optional) + // Localizer! // Module's localizer + // ); + // This will translate the menu name per-player based on their css_lang! + // See FunCommands module for real example. + _menusRegistered = true; Logger.LogInformation("Example menus registered successfully!"); } diff --git a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdminApi.dll b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdminApi.dll index b6fa68c..939cd65 100644 Binary files a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdminApi.dll and b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdminApi.dll differ diff --git a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.cs b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.cs index 729ab08..d7cc180 100644 --- a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.cs +++ b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.cs @@ -366,66 +366,73 @@ public partial class CS2_SimpleAdmin_FunCommands : BasePlugin, IPluginConfig 0) _sharedApi.RegisterMenu("fun", "god", - Localizer?["fun_menu_god"] ?? "God Mode", - CreateGodModeMenu, "@css/cheats", "css_god"); + "fun_menu_god", + CreateGodModeMenu, "@css/cheats", "css_god", Localizer!); if (Config.NoclipCommands.Count > 0) _sharedApi.RegisterMenu("fun", "noclip", - Localizer?["fun_menu_noclip"] ?? "No Clip", - CreateNoClipMenu, "@css/cheats", "css_noclip"); + "fun_menu_noclip", + CreateNoClipMenu, "@css/cheats", "css_noclip", Localizer!); if (Config.RespawnCommands.Count > 0) _sharedApi.RegisterMenu("fun", "respawn", - Localizer?["fun_menu_respawn"] ?? "Respawn", - CreateRespawnMenu, "@css/cheats", "css_respawn"); + "fun_menu_respawn", + CreateRespawnMenu, "@css/cheats", "css_respawn", Localizer!); if (Config.GiveCommands.Count > 0) _sharedApi.RegisterMenu("fun", "give", - Localizer?["fun_menu_give"] ?? "Give Weapon", - CreateGiveWeaponMenu, "@css/cheats", "css_give"); + "fun_menu_give", + CreateGiveWeaponMenu, "@css/cheats", "css_give", Localizer!); if (Config.StripCommands.Count > 0) _sharedApi.RegisterMenu("fun", "strip", - Localizer?["fun_menu_strip"] ?? "Strip Weapons", - CreateStripWeaponsMenu, "@css/slay", "css_strip"); + "fun_menu_strip", + CreateStripWeaponsMenu, "@css/slay", "css_strip", Localizer!); if (Config.FreezeCommands.Count > 0) _sharedApi.RegisterMenu("fun", "freeze", - Localizer?["fun_menu_freeze"] ?? "Freeze", - CreateFreezeMenu, "@css/slay", "css_freeze"); + "fun_menu_freeze", + CreateFreezeMenu, "@css/slay", "css_freeze", Localizer!); if (Config.HpCommands.Count > 0) _sharedApi.RegisterMenu("fun", "hp", - Localizer?["fun_menu_hp"] ?? "Set HP", - CreateSetHpMenu, "@css/slay", "css_hp"); + "fun_menu_hp", + CreateSetHpMenu, "@css/slay", "css_hp", Localizer!); if (Config.SpeedCommands.Count > 0) _sharedApi.RegisterMenu("fun", "speed", - Localizer?["fun_menu_speed"] ?? "Set Speed", - CreateSetSpeedMenu, "@css/slay", "css_speed"); + "fun_menu_speed", + CreateSetSpeedMenu, "@css/slay", "css_speed", Localizer!); if (Config.GravityCommands.Count > 0) _sharedApi.RegisterMenu("fun", "gravity", - Localizer?["fun_menu_gravity"] ?? "Set Gravity", - CreateSetGravityMenu, "@css/slay", "css_gravity"); + "fun_menu_gravity", + CreateSetGravityMenu, "@css/slay", "css_gravity", Localizer!); if (Config.MoneyCommands.Count > 0) _sharedApi.RegisterMenu("fun", "money", - Localizer?["fun_menu_money"] ?? "Set Money", - CreateSetMoneyMenu, "@css/slay", "css_money"); + "fun_menu_money", + CreateSetMoneyMenu, "@css/slay", "css_money", Localizer!); if (Config.ResizeCommands.Count > 0) _sharedApi.RegisterMenu("fun", "resize", - Localizer?["fun_menu_resize"] ?? "Resize Player", - CreateSetResizeMenu, "@css/slay", "css_resize"); + "fun_menu_resize", + CreateSetResizeMenu, "@css/slay", "css_resize", Localizer!); _menusRegistered = true; Logger.LogInformation("Fun menus registered successfully!"); diff --git a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.csproj b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.csproj index 3990512..85c6be5 100644 --- a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.csproj +++ b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands.csproj @@ -14,7 +14,7 @@ - + diff --git a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/Menus.cs b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/Menus.cs index a492787..ea8d90e 100644 --- a/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/Menus.cs +++ b/Modules/CS2-SimpleAdmin_FunCommands/CS2-SimpleAdmin_FunCommands/Menus.cs @@ -1,6 +1,7 @@ using System.Globalization; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Translations; namespace CS2_SimpleAdmin_FunCommands; @@ -139,8 +140,15 @@ public partial class CS2_SimpleAdmin_FunCommands /// private object CreateWeaponSelectionMenu(CCSPlayerController admin, CCSPlayerController target) { + // Translate title per-player based on admin's css_lang + string translatedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + translatedTitle = Localizer?["fun_menu_give_player", target.PlayerName] ?? $"Give Weapon: {target.PlayerName}"; + } + var weaponMenu = _sharedApi!.CreateMenuWithBack( - Localizer?["fun_menu_give_player", target.PlayerName] ?? $"Give Weapon: {target.PlayerName}", + translatedTitle, "fun", admin); @@ -212,8 +220,15 @@ public partial class CS2_SimpleAdmin_FunCommands /// private object CreateHpSelectionMenu(CCSPlayerController admin, CCSPlayerController target) { + // Translate title per-player based on admin's css_lang + string translatedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + translatedTitle = Localizer?["fun_menu_hp_player", target.PlayerName] ?? $"Set HP: {target.PlayerName}"; + } + var hpSelectionMenu = _sharedApi!.CreateMenuWithBack( - Localizer?["fun_menu_hp_player", target.PlayerName] ?? $"Set HP: {target.PlayerName}", + translatedTitle, "fun", admin); @@ -222,8 +237,15 @@ public partial class CS2_SimpleAdmin_FunCommands foreach (var hp in hpValues) { + // Translate option label per-player + string optionLabel; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + optionLabel = Localizer?["fun_menu_hp_value", hp] ?? $"{hp} HP"; + } + _sharedApi.AddMenuOption(hpSelectionMenu, - Localizer?["fun_menu_hp_value", hp] ?? $"{hp} HP", + optionLabel, _ => { if (target.IsValid) @@ -261,8 +283,15 @@ public partial class CS2_SimpleAdmin_FunCommands /// private object CreateSpeedSelectionMenu(CCSPlayerController admin, CCSPlayerController target) { + // Translate title per-player based on admin's css_lang + string translatedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + translatedTitle = Localizer?["fun_menu_speed_player", target.PlayerName] ?? $"Set Speed: {target.PlayerName}"; + } + var speedSelectionMenu = _sharedApi!.CreateMenuWithBack( - Localizer?["fun_menu_speed_player", target.PlayerName] ?? $"Set Speed: {target.PlayerName}", + translatedTitle, "fun", admin); @@ -275,8 +304,15 @@ public partial class CS2_SimpleAdmin_FunCommands foreach (var (speed, display) in speedValues) { + // Translate option label per-player + string optionLabel; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + optionLabel = Localizer?["fun_menu_speed_value", display] ?? $"Speed {display}"; + } + _sharedApi.AddMenuOption(speedSelectionMenu, - Localizer?["fun_menu_speed_value", display] ?? $"Speed {display}", + optionLabel, _ => { if (target.IsValid) @@ -316,8 +352,15 @@ public partial class CS2_SimpleAdmin_FunCommands private object CreateGravitySelectionMenu(CCSPlayerController admin, CCSPlayerController target) { + // Translate title per-player based on admin's css_lang + string translatedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + translatedTitle = Localizer?["fun_menu_gravity_player", target.PlayerName] ?? $"Set Gravity: {target.PlayerName}"; + } + var gravitySelectionMenu = _sharedApi!.CreateMenuWithBack( - Localizer?["fun_menu_gravity_player", target.PlayerName] ?? $"Set Gravity: {target.PlayerName}", + translatedTitle, "fun", admin); var gravityValues = new[] @@ -365,8 +408,15 @@ public partial class CS2_SimpleAdmin_FunCommands private object CreateMoneySelectionMenu(CCSPlayerController admin, CCSPlayerController target) { + // Translate title per-player based on admin's css_lang + string translatedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + translatedTitle = Localizer?["fun_menu_money_player", target.PlayerName] ?? $"Set Money: {target.PlayerName}"; + } + var moneySelectionMenu = _sharedApi!.CreateMenuWithBack( - Localizer?["fun_menu_money_player", target.PlayerName] ?? $"Set Money: {target.PlayerName}", + translatedTitle, "fun", admin); var moneyValues = new[] { 0, 1000, 2500, 5000, 10000, 16000 }; @@ -407,8 +457,15 @@ public partial class CS2_SimpleAdmin_FunCommands private object CreateResizeSelectionMenu(CCSPlayerController admin, CCSPlayerController target) { + // Translate title per-player based on admin's css_lang + string translatedTitle; + using (new WithTemporaryCulture(admin.GetLanguage())) + { + translatedTitle = Localizer?["fun_menu_resize_player", target.PlayerName] ?? $"Resize: {target.PlayerName}"; + } + var resizeSelectionMenu = _sharedApi!.CreateMenuWithBack( - Localizer?["fun_menu_resize_player", target.PlayerName] ?? $"Resize: {target.PlayerName}", + translatedTitle, "fun", admin); var resizeValues = new[] diff --git a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.0-linux.tar.gz b/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.0-linux.tar.gz deleted file mode 100644 index ae1a40d..0000000 Binary files a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.0-linux.tar.gz and /dev/null differ diff --git a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.0-windows.tar.gz b/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.0-windows.tar.gz deleted file mode 100644 index 1ec754a..0000000 Binary files a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.0-windows.tar.gz and /dev/null differ diff --git a/Modules/MODULE_DEVELOPMENT.md b/Modules/MODULE_DEVELOPMENT.md index cba01f0..4dee295 100644 --- a/Modules/MODULE_DEVELOPMENT.md +++ b/Modules/MODULE_DEVELOPMENT.md @@ -75,10 +75,17 @@ public class MyModule : BasePlugin if (_api == null) return; // 1. Register a new category + // IMPORTANT: If your module has lang/ folder with translations, use YOUR module's Localizer! + // Example: _api.RegisterMenuCategory("mymodule", Localizer?["mymodule_category_name"] ?? "My Module", "@css/generic"); + // + // This will use YOUR module's translations in server language. + // For modules without translations, you can use hard-coded text: _api.RegisterMenuCategory("mymodule", "My Module", "@css/generic"); // 2. Register menu items in the category // 🆕 NEW: Use MenuContext-aware overload (no duplication!) + // NOTE: Use YOUR module's Localizer if you have lang/ folder with translations: + // _api.RegisterMenu("mymodule", "action1", Localizer?["mymodule_menu_action1"] ?? "Action 1", CreateAction1Menu, "@css/generic"); _api.RegisterMenu("mymodule", "action1", "Action 1", CreateAction1Menu, "@css/generic"); _api.RegisterMenu("mymodule", "action2", "Action 2", CreateAction2Menu, "@css/kick"); } @@ -356,12 +363,27 @@ Registers a new menu category that appears in the main admin menu. **Parameters:** - `categoryId` - Unique identifier for the category (e.g., "fun", "vip", "economy") -- `categoryName` - Display name shown in menu (e.g., "Fun Commands") +- `categoryName` - **TRANSLATION KEY** for the display name (e.g., "vip_category_name", NOT "VIP Features") - `permission` - Required permission to see the category (default: "@css/generic") +**IMPORTANT FOR MODULES WITH TRANSLATIONS:** +- If your module has a `lang/` folder with translation files, use **YOUR module's Localizer** +- This will display menu names in the **server's language** (not per-player) +- For per-player localization, only **CS2-SimpleAdmin's built-in menus** support this currently + **Example:** ```csharp +// ✅ CORRECT: Module with translations uses its own Localizer +_api.RegisterMenuCategory("vip", Localizer?["vip_category_name"] ?? "VIP Features", "@css/vip"); +// In YOUR module's lang/en.json: "vip_category_name": "VIP Features" +// In YOUR module's lang/pl.json: "vip_category_name": "Funkcje VIP" + +// ✅ ALSO CORRECT: Module without translations uses hard-coded text _api.RegisterMenuCategory("vip", "VIP Features", "@css/vip"); + +// ❌ INCORRECT: Using translation key without Localizer +_api.RegisterMenuCategory("vip", "vip_category_name", "@css/vip"); +// This would display "vip_category_name" literally! ``` ### 2. Menu Registration @@ -373,13 +395,28 @@ Registers a menu item within a category. **Parameters:** - `categoryId` - The category to add this menu to - `menuId` - Unique identifier for the menu -- `menuName` - Display name in the category menu -- `menuFactory` - Function that creates the menu when selected (receives admin player) +- `menuName` - Display name for the menu (use **your module's Localizer** if you have translations) +- `menuFactory` - Function that creates the menu when selected (receives admin player and MenuContext) - `permission` - Optional permission required to see this menu item +**IMPORTANT FOR MODULES WITH TRANSLATIONS:** +- Use **your module's Localizer** if you have a `lang/` folder: `Localizer?["key"] ?? "Fallback"` +- This shows menu in **server language**, not per-player +- Per-player localization is only available for CS2-SimpleAdmin's built-in menus + **Example:** ```csharp +// ✅ CORRECT: Module with translations uses its own Localizer +_api.RegisterMenu("fun", "godmode", Localizer?["fun_menu_god"] ?? "God Mode", CreateGodModeMenu, "@css/cheats"); +// In YOUR module's lang/en.json: "fun_menu_god": "God Mode" +// In YOUR module's lang/pl.json: "fun_menu_god": "Tryb Boga" + +// ✅ ALSO CORRECT: Module without translations uses hard-coded text _api.RegisterMenu("fun", "godmode", "God Mode", CreateGodModeMenu, "@css/cheats"); + +// ❌ INCORRECT: Using translation key without Localizer +_api.RegisterMenu("fun", "godmode", "fun_menu_god", CreateGodModeMenu, "@css/cheats"); +// This would display "fun_menu_god" literally! ``` #### `UnregisterMenu(string categoryId, string menuId)` @@ -625,12 +662,32 @@ private object CreateAdminToolsMenu(CCSPlayerController admin) ## Best Practices -1. **Always check for API availability** +1. **Use your module's Localizer for translations** 🌍 + ```csharp + // ✅ CORRECT: Module with translations uses its own Localizer + _api.RegisterMenuCategory("mymodule", Localizer?["mymodule_category"] ?? "My Module", "@css/generic"); + _api.RegisterMenu("mymodule", "action", Localizer?["mymodule_menu_action"] ?? "My Action", CreateMenu, "@css/generic"); + + // ✅ ALSO CORRECT: Module without translations uses hard-coded text + _api.RegisterMenuCategory("mymodule", "My Module", "@css/generic"); + _api.RegisterMenu("mymodule", "action", "My Action", CreateMenu, "@css/generic"); + + // ❌ WRONG: Using translation key without Localizer + _api.RegisterMenuCategory("mymodule", "mymodule_category", "@css/generic"); + // This would display "mymodule_category" literally! + ``` + + **✅ Per-player localization now available!** + - Both CS2-SimpleAdmin built-in menus AND module menus support per-player localization + - Each player sees menus in their own language based on their `css_lang` setting + - See "Advanced: Per-Player Localization for Modules" section below for implementation details + +2. **Always check for API availability** ```csharp if (_api == null) return; ``` -2. **Validate player state before actions** +3. **Validate player state before actions** ```csharp if (target.IsValid && target.PlayerPawn?.Value != null) { @@ -638,11 +695,11 @@ private object CreateAdminToolsMenu(CCSPlayerController admin) } ``` -3. **Use descriptive category and menu IDs** +4. **Use descriptive category and menu IDs** - Good: `"economy"`, `"vip_features"`, `"fun_commands"` - Bad: `"cat1"`, `"menu"`, `"test"` -4. **Clean up on unload** +5. **Clean up on unload** ```csharp public override void Unload(bool hotReload) { @@ -651,14 +708,14 @@ private object CreateAdminToolsMenu(CCSPlayerController admin) } ``` -5. **Use appropriate permissions** +6. **Use appropriate permissions** - `@css/generic` - All admins - `@css/ban` - Admins who can ban - `@css/kick` - Admins who can kick - `@css/root` - Root admins only - Custom permissions from your module -6. **Handle hot reload** +7. **Handle hot reload** ```csharp _api.OnSimpleAdminReady += RegisterMenus; RegisterMenus(); // Fallback for hot reload case @@ -717,3 +774,72 @@ See the `CS2-SimpleAdmin_FunCommands` module in the `Modules/` directory for a c **Q: API is null in OnAllPluginsLoaded** - Wait for the `OnSimpleAdminReady` event instead of immediate registration - Make sure CS2-SimpleAdmin is loaded before your module + +## Advanced: Per-Player Localization for Modules (✅ NOW AVAILABLE!) + +**🆕 NEW:** Module menus now support **per-player localization** based on `css_lang`! + +Both CS2-SimpleAdmin's built-in menus AND module menus can show in each player's configured language. + +### How to Use Per-Player Localization in Your Module + +**1. Register Category with Localizer:** + +```csharp +// Pass translation KEY (not translated text) and module's Localizer +_api.RegisterMenuCategory( + "mymodule", + "mymodule_category_name", // Translation key from your lang/ folder + "@css/generic", + Localizer! // Your module's localizer +); +``` + +**2. Register Menus with Localizer:** + +```csharp +_api.RegisterMenu( + "mymodule", + "action", + "mymodule_menu_action", // Translation key from your lang/ folder + CreateMenu, + "@css/generic", // Permission + "css_mycommand", // Command name for override (optional) + Localizer! // Your module's localizer +); +``` + +**How it works:** +1. Your module passes its `IStringLocalizer` and translation **key** (not translated text) +2. SimpleAdmin's `MenuManager` stores both the key and the localizer +3. When displaying menu to a player, MenuManager translates using: + ```csharp + using (new WithTemporaryCulture(player.GetLanguage())) + { + localizedName = moduleLocalizer[translationKey]; + } + ``` +4. Each player sees menus in their own language based on their `css_lang` setting! + +**Complete Example:** + +See `Modules/CS2-SimpleAdmin_FunCommands/` for a real implementation: + +```csharp +// Register category with per-player localization +_api.RegisterMenuCategory("fun", "fun_category_name", "@css/generic", Localizer!); + +// Register menu with per-player localization +_api.RegisterMenu("fun", "god", "fun_menu_god", + CreateGodModeMenu, "@css/cheats", "css_god", Localizer!); +``` + +**Without Per-Player Localization (backwards compatible):** + +If you don't need per-player localization, the old API still works: + +```csharp +// Hard-coded text (same for all players) +_api.RegisterMenuCategory("mymodule", "My Module", "@css/generic"); +_api.RegisterMenu("mymodule", "action", "Do Action", CreateMenu, "@css/generic"); +```