Compare commits

..

No commits in common. "main" and "build-1.7.8-beta-3" have entirely different histories.

46 changed files with 820 additions and 1277 deletions

View file

@ -35,7 +35,7 @@ jobs:
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 10.0.x dotnet-version: 8.0.x
- name: Get Version - name: Get Version
id: get_version id: get_version
@ -67,12 +67,28 @@ jobs:
- name: Zip Main Build Output - name: Zip Main Build Output
run: zip -r CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip ${{ env.OUTPUT_PATH }} 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 - name: Upload all artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: CS2-SimpleAdmin-Build-Artifacts name: CS2-SimpleAdmin-Build-Artifacts
path: | path: |
CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip 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: publish:
needs: build needs: build
@ -93,6 +109,8 @@ jobs:
with: with:
artifacts: | artifacts: |
CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}.zip 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 }}" name: "CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}"
tag: "build-${{ needs.build.outputs.build_version }}" tag: "build-${{ needs.build.outputs.build_version }}"
body: | body: |
@ -103,5 +121,8 @@ jobs:
After the first launch, configure the plugin using the JSON config file at: After the first launch, configure the plugin using the JSON config file at:
addons/counterstrikesharp/configs/plugins/CS2-SimpleAdmin/CS2-SimpleAdmin.json 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. Remember to restart or reload your game server after installing and configuring the plugins.

2
.gitignore vendored
View file

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

View file

@ -1,6 +1,5 @@
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Commands; using CounterStrikeSharp.API.Core.Commands;
using CounterStrikeSharp.API.Core.Translations;
using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Commands.Targeting; using CounterStrikeSharp.API.Modules.Commands.Targeting;
using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities;
@ -206,14 +205,6 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi
Menus.MenuManager.Instance.RegisterCategory(categoryId, categoryName, permission); 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, public void RegisterMenu(string categoryId, string menuId, string menuName,
Func<CCSPlayerController, object> menuFactory, string? permission = null, string? commandName = null) Func<CCSPlayerController, object> menuFactory, string? permission = null, string? commandName = null)
{ {
@ -272,39 +263,6 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi
} }
} }
public void RegisterMenu(string categoryId, string menuId, string menuNameKey,
Func<CCSPlayerController, MenuContext, object> 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) public void UnregisterMenu(string categoryId, string menuId)
{ {
@ -331,30 +289,7 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi
public object CreateMenuWithBack(MenuContext context, CCSPlayerController player) public object CreateMenuWithBack(MenuContext context, CCSPlayerController player)
{ {
// Get translated title if module has localizer return CreateMenuWithBack(context.MenuTitle, context.CategoryId, player);
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<CCSPlayerController> GetValidPlayers() public List<CCSPlayerController> GetValidPlayers()
@ -386,30 +321,7 @@ public class CS2_SimpleAdminApi : ICS2_SimpleAdminApi
public object CreateMenuWithPlayers(MenuContext context, CCSPlayerController admin, public object CreateMenuWithPlayers(MenuContext context, CCSPlayerController admin,
Func<CCSPlayerController, bool> filter, Action<CCSPlayerController, CCSPlayerController> onSelect) Func<CCSPlayerController, bool> filter, Action<CCSPlayerController, CCSPlayerController> onSelect)
{ {
// Get translated title if module has localizer return CreateMenuWithPlayers(context.MenuTitle, context.CategoryId, admin, filter, onSelect);
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<CCSPlayerController> action, bool disabled = false, public void AddMenuOption(object menu, string name, Action<CCSPlayerController> action, bool disabled = false,

View file

@ -14,19 +14,20 @@ using MySqlConnector;
namespace CS2_SimpleAdmin; namespace CS2_SimpleAdmin;
[MinimumApiVersion(369)] [MinimumApiVersion(300)]
public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdminConfig> public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdminConfig>
{ {
internal static CS2_SimpleAdmin Instance { get; private set; } = new(); internal static CS2_SimpleAdmin Instance { get; private set; } = new();
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
public override string ModuleAuthor => "daffyy"; public override string ModuleAuthor => "daffyy & Dliix66";
public override string ModuleVersion => "1.8.2b"; public override string ModuleVersion => "1.7.8-beta-3";
public override void Load(bool hotReload) public override void Load(bool hotReload)
{ {
Instance = this; Instance = this;
if (hotReload) if (hotReload)
{ {
ServerLoaded = false; ServerLoaded = false;
@ -46,7 +47,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
CachedPlayers.Clear(); CachedPlayers.Clear();
BotPlayers.Clear(); BotPlayers.Clear();
foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && p is { Connected: PlayerConnectedState.Connected, IsHLTV: false }).ToArray()) foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsHLTV).ToArray())
{ {
if (!player.IsBot) if (!player.IsBot)
PlayerManager.LoadPlayerData(player, true); PlayerManager.LoadPlayerData(player, true);
@ -55,12 +56,14 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
}; };
}); });
PlayersTimer?.Kill();
PlayersTimer = null;
} }
_cBasePlayerControllerSetPawnFunc = new MemoryFunctionVoid<CBasePlayerController, CCSPlayerPawn, bool, bool>(GameData.GetSignature("CBasePlayerController_SetPawn")); _cBasePlayerControllerSetPawnFunc = new MemoryFunctionVoid<CBasePlayerController, CCSPlayerPawn, bool, bool>(GameData.GetSignature("CBasePlayerController_SetPawn"));
SimpleAdminApi = new Api.CS2_SimpleAdminApi(); SimpleAdminApi = new Api.CS2_SimpleAdminApi();
Capabilities.RegisterPluginCapability(ICS2_SimpleAdminApi.PluginCapability, () => SimpleAdminApi); Capabilities.RegisterPluginCapability(ICS2_SimpleAdminApi.PluginCapability, () => SimpleAdminApi);
PlayersTimer?.Kill(); PlayersTimer?.Kill();
PlayersTimer = null; PlayersTimer = null;
PlayerManager.CheckPlayersTimer(); PlayerManager.CheckPlayersTimer();
@ -81,9 +84,9 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
Unload(false); Unload(false);
} }
AddTimer(6.0f, () => ReloadAdmins(null));
RegisterEvents(); RegisterEvents();
AddTimer(0.5f, RegisterCommands.InitializeCommands); AddTimer(0.5f, RegisterCommands.InitializeCommands);
AddTimer(3.0f, () => ReloadAdmins(null));
if (!CoreConfig.UnlockConCommands) if (!CoreConfig.UnlockConCommands)
{ {
@ -93,11 +96,20 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
$"to rejoin the server for 60 seconds. " + $"to rejoin the server for 60 seconds. " +
$"To enable instant banning, set 'UnlockConCommands': true" $"To enable instant banning, set 'UnlockConCommands': true"
); );
_logger?.LogError(
$"⚠️ Warning: 'UnlockConCommands' is disabled in core.json. " +
$"Players will not be automatically banned when kicked and will be able " +
$"to rejoin the server for 60 seconds. " +
$"To enable instant banning, set 'UnlockConCommands': true"
);
} }
} }
public void OnConfigParsed(CS2_SimpleAdminConfig config) public void OnConfigParsed(CS2_SimpleAdminConfig config)
{ {
if (System.Diagnostics.Debugger.IsAttached)
Environment.FailFast(":(!");
Helper.UpdateConfig(config); Helper.UpdateConfig(config);
_logger = Logger; _logger = Logger;
@ -153,11 +165,8 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
} }
if (missing) if (missing)
{ Server.ExecuteCommand($"css_plugins unload {ModuleName}");
Server.ExecuteCommand($"css_plugins unload {ModuleDirectory}");
return;
}
Instance = this; Instance = this;
if (Config.DatabaseConfig.DatabaseType.Contains("mysql", StringComparison.CurrentCultureIgnoreCase)) if (Config.DatabaseConfig.DatabaseType.Contains("mysql", StringComparison.CurrentCultureIgnoreCase))
@ -252,7 +261,6 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
CacheManager = null; CacheManager = null;
PlayersTimer?.Kill(); PlayersTimer?.Kill();
PlayersTimer = null; PlayersTimer = null;
UnregisterEvents(); UnregisterEvents();
if (hotReload) if (hotReload)

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>CS2_SimpleAdmin</RootNamespace> <RootNamespace>CS2_SimpleAdmin</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -19,16 +19,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.369"> <PackageReference Include="CounterStrikeSharp.API" Version="1.0.340">
<PrivateAssets>none</PrivateAssets> <PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Dapper" Version="2.1.79" /> <PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="MySqlConnector" Version="2.6.0" /> <PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" /> <PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="6.0.3" />
<PackageReference Include="ZLinq" Version="1.5.6" /> <PackageReference Include="ZLinq" Version="1.5.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

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

View file

@ -501,13 +501,13 @@ public partial class CS2_SimpleAdmin
Task.Run(async () => Task.Run(async () =>
{ {
await PermissionManager.CreateGroupsJsonFile(); await PermissionManager.CrateGroupsJsonFile();
await PermissionManager.CreateAdminsJsonFile(); await PermissionManager.CreateAdminsJsonFile();
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");
var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json"); var groupsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/groups.json");
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
AddTimer(1, () => AddTimer(1, () =>
{ {
@ -521,7 +521,7 @@ public partial class CS2_SimpleAdmin
_logger?.LogInformation("Loaded admins!"); _logger?.LogInformation("Loaded admins!");
}); });
}); });
}); });
//_ = _adminManager.GiveAllGroupsFlags(); //_ = _adminManager.GiveAllGroupsFlags();
//_ = _adminManager.GiveAllFlags(); //_ = _adminManager.GiveAllFlags();

View file

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

View file

@ -12,9 +12,6 @@ public interface IDatabaseProvider
string GetBanSelectQuery(bool multiServer); string GetBanSelectQuery(bool multiServer);
string GetIpHistoryQuery(); string GetIpHistoryQuery();
string GetBanUpdateQuery(bool multiServer); string GetBanUpdateQuery(bool multiServer);
// PlayerManager
string GetUpsertPlayerIpQuery();
// PermissionManager // PermissionManager
string GetAdminsQuery(); string GetAdminsQuery();
@ -40,7 +37,6 @@ public interface IDatabaseProvider
string GetUpdateBanStatusQuery(); string GetUpdateBanStatusQuery();
string GetExpireBansQuery(bool multiServer); string GetExpireBansQuery(bool multiServer);
string GetExpireIpBansQuery(bool multiServer); string GetExpireIpBansQuery(bool multiServer);
string GetExpireOldPlayerIpsQuery();
// MuteManager // MuteManager
string GetAddMuteQuery(bool includePlayerName); string GetAddMuteQuery(bool includePlayerName);

View file

@ -1,33 +1,33 @@
-- -- Migration 016: Optimize tables and indexes -- Migration 016: Optimize tables and indexes
-- -- Add proper indexes for all tables to improve query performance -- Add proper indexes for all tables to improve query performance
-- -- Optimize sa_players_ips table indexes -- Optimize sa_players_ips table indexes
-- -- Add index on used_at for efficient date-based queries -- 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); ALTER TABLE `sa_players_ips` ADD INDEX IF NOT EXISTS `idx_used_at` (`used_at` DESC);
-- -- Optimize sa_bans table indexes -- Optimize sa_bans table indexes
-- -- Add composite indexes for common query patterns -- 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_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_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_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_server_status` ON `sa_bans` (`server_id`, `status`, `ends`);
-- CREATE INDEX IF NOT EXISTS `idx_bans_created` ON `sa_bans` (`created` DESC); CREATE INDEX IF NOT EXISTS `idx_bans_created` ON `sa_bans` (`created` DESC);
-- -- Optimize sa_admins table indexes -- 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_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_server_ends` ON `sa_admins` (`server_id`, `ends`);
-- CREATE INDEX IF NOT EXISTS `idx_admins_ends` ON `sa_admins` (`ends`); CREATE INDEX IF NOT EXISTS `idx_admins_ends` ON `sa_admins` (`ends`);
-- -- Optimize sa_mutes table indexes (in addition to migration 014) -- Optimize sa_mutes table indexes (in addition to migration 014)
-- -- Add index for expire queries -- 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_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_server_status` ON `sa_mutes` (`server_id`, `status`, `ends`);
-- CREATE INDEX IF NOT EXISTS `idx_mutes_created` ON `sa_mutes` (`created` DESC); CREATE INDEX IF NOT EXISTS `idx_mutes_created` ON `sa_mutes` (`created` DESC);
-- -- Optimize sa_warns table indexes (if exists) -- 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_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_status_ends` ON `sa_warns` (`status`, `ends`);
-- CREATE INDEX IF NOT EXISTS `idx_warns_server_status` ON `sa_warns` (`server_id`, `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 -- Add index on sa_servers for faster lookups
-- CREATE INDEX IF NOT EXISTS `idx_servers_hostname` ON `sa_servers` (`hostname`); CREATE INDEX IF NOT EXISTS `idx_servers_hostname` ON `sa_servers` (`hostname`);

View file

@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS `sa_players_ips` ( CREATE TABLE IF NOT EXISTS `sa_players_ips` (
`steamid` INTEGER NOT NULL, `steamid` INTEGER NOT NULL,
`address` INTEGER NOT NULL, `address` INTEGER NOT NULL
`used_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `used_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`steamid`, `address`) PRIMARY KEY (`steamid`, `address`)
); );

View file

@ -1,4 +1,4 @@
DELETE FROM sa_players_ips; TRUNCATE TABLE `sa_players_ips`;
ALTER TABLE `sa_players_ips` ADD `name` VARCHAR(64) NULL DEFAULT NULL; 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); CREATE INDEX IF NOT EXISTS `idx_sa_players_ips_used_at` ON `sa_players_ips` (`used_at` DESC);

View file

@ -0,0 +1,33 @@
-- 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`);

View file

@ -15,7 +15,7 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
cmd.CommandText = "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci';"; cmd.CommandText = "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci';";
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
// cmd.CommandText = "SET time_zone = '+00:00';"; cmd.CommandText = "SET time_zone = '+00:00';";
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
return connection; return connection;
@ -86,17 +86,6 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
return "SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY used_at DESC"; 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) public string GetBanUpdateQuery(bool multiServer)
{ {
return multiServer ? """ return multiServer ? """
@ -251,11 +240,6 @@ 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"
: "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid"; : "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) => public string GetAddMuteQuery(bool includePlayerName) =>
includePlayerName includePlayerName

View file

@ -83,17 +83,6 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
public string GetIpHistoryQuery() => public string GetIpHistoryQuery() =>
"SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY used_at DESC"; "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) => public string GetBanUpdateQuery(bool multiServer) =>
multiServer multiServer
? """ ? """
@ -166,9 +155,6 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider
multiServer 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"
: "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid"; : "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() => public string GetAdminsQuery() =>
""" """

View file

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

View file

@ -11,14 +11,12 @@ using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CS2_SimpleAdminApi; using CS2_SimpleAdminApi;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using CounterStrikeSharp.API.Core.Plugin.Host; using CounterStrikeSharp.API.Core.Plugin.Host;
using CounterStrikeSharp.API.Modules.Entities.Constants; using CounterStrikeSharp.API.Modules.Entities.Constants;
@ -66,7 +64,7 @@ internal static class Helper
public static List<CCSPlayerController> GetValidPlayers() public static List<CCSPlayerController> GetValidPlayers()
{ {
return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().Where(p => p.IsValid && p.Connected == PlayerConnectedState.Connected).ToList(); return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().ToList();
} }
public static List<CCSPlayerController> GetValidPlayersWithBots() public static List<CCSPlayerController> GetValidPlayersWithBots()
@ -428,7 +426,7 @@ internal static class Helper
var communityUrl = caller != null ? "<" + new SteamID(caller.SteamID).ToCommunityUrl() + ">" : "<https://steamcommunity.com/profiles/0>"; var communityUrl = caller != null ? "<" + new SteamID(caller.SteamID).ToCommunityUrl() + ">" : "<https://steamcommunity.com/profiles/0>";
var callerName = caller != null ? caller.PlayerName : CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console"; var callerName = caller != null ? caller.PlayerName : CS2_SimpleAdmin._localizer?["sa_console"] ?? "Console";
_ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command.GetCommandString])); _ = CS2_SimpleAdmin.DiscordWebhookClientLog.SendMessageAsync(Helper.GenerateMessageDiscord(localizer["sa_discord_log_command", $"[{callerName}]({communityUrl})", command.GetCommandString]));
} }
private static void SendDiscordLogMessage(CCSPlayerController? caller, string command, IStringLocalizer? localizer) private static void SendDiscordLogMessage(CCSPlayerController? caller, string command, IStringLocalizer? localizer)
@ -585,32 +583,27 @@ internal static class Helper
} }
public static void DisplayCenterMessage( public static void DisplayCenterMessage(
CCSPlayerController player, CCSPlayerController player,
string messageKey, string messageKey,
string? callerName = null, string? callerName = null,
params object[] messageArgs) params object[] messageArgs)
{ {
if (CS2_SimpleAdmin._localizer == null) return; if (CS2_SimpleAdmin._localizer == null) return;
// Determine the localized message key
var localizedMessageKey = $"{messageKey}"; var localizedMessageKey = $"{messageKey}";
var formattedMessageArgs = messageArgs.Select(arg => arg?.ToString() ?? string.Empty).ToArray(); var formattedMessageArgs = messageArgs.Select(arg => arg?.ToString() ?? string.Empty).ToArray();
// Replace placeholder based on showActivityType
for (var i = 0; i < formattedMessageArgs.Length; i++) for (var i = 0; i < formattedMessageArgs.Length; i++)
{ {
var arg = formattedMessageArgs[i]; // Convert argument to string if not null var arg = formattedMessageArgs[i];
// Replace "CALLER" placeholder in the argument string
formattedMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch formattedMessageArgs[i] = CS2_SimpleAdmin.Instance.Config.OtherSettings.ShowActivityType switch
{ {
1 => arg.Replace("CALLER", CS2_SimpleAdmin._localizer["sa_admin"]), 1 => arg.Replace("CALLER", CS2_SimpleAdmin._localizer["sa_admin"]),
2 => arg.Replace("CALLER", callerName ?? "Console"),
_ => arg _ => arg
}; };
} }
// Print the localized message to the center of the screen for the player
using (new WithTemporaryCulture(player.GetLanguage())) using (new WithTemporaryCulture(player.GetLanguage()))
{ {
player.PrintToCenter(CS2_SimpleAdmin._localizer[localizedMessageKey, formattedMessageArgs.Cast<object>().ToArray()]); player.PrintToCenter(CS2_SimpleAdmin._localizer[localizedMessageKey, formattedMessageArgs.Cast<object>().ToArray()]);
@ -857,34 +850,26 @@ internal static class Helper
} }
} }
public static void UpdateConfig(BasePluginConfig config) public static void UpdateConfig<T>(T config) where T : BasePluginConfig, new()
{ {
// get newest config version // get newest config version
var configType = config.GetType(); var newCfgVersion = new T().Version;
var newCfgVersion = ((BasePluginConfig)Activator.CreateInstance(configType)!).Version;
// loaded config is up to date // loaded config is up to date
if (config.Version == newCfgVersion) if (config.Version == newCfgVersion)
return; return;
// Load existing JSON file and update version property // update the version
if (!File.Exists(CfgPath)) config.Version = newCfgVersion;
return;
var json = File.ReadAllText(CfgPath); // serialize the updated config back to json
var node = JsonNode.Parse(json); var updatedJsonContent = JsonSerializer.Serialize(config,
new JsonSerializerOptions
if (node != null)
{
node["Version"] = newCfgVersion;
var updatedJsonContent = node.ToJsonString(new JsonSerializerOptions
{ {
WriteIndented = true, WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}); });
File.WriteAllText(CfgPath, updatedJsonContent);
File.WriteAllText(CfgPath, updatedJsonContent);
}
} }
public static void TryLogCommandOnDiscord(CCSPlayerController? caller, string commandString) public static void TryLogCommandOnDiscord(CCSPlayerController? caller, string commandString)
@ -1011,10 +996,6 @@ public static class PluginInfo
Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|"); Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|");
Console.WriteLine(" >> Version: " + moduleVersion); Console.WriteLine(" >> Version: " + moduleVersion);
Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin"); Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin");
var _m1 = new List<int> { 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<int> { 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(" "); Console.WriteLine(" ");
} }
} }
@ -1045,9 +1026,7 @@ public static class Time
{ {
public static DateTime ActualDateTime() public static DateTime ActualDateTime()
{ {
if (CS2_SimpleAdmin.Instance.Config.DatabaseConfig.DatabaseType.ToLower().Equals("sqlite")) return DateTime.UtcNow;
return DateTime.UtcNow;
string timezoneId = CS2_SimpleAdmin.Instance.Config.Timezone; string timezoneId = CS2_SimpleAdmin.Instance.Config.Timezone;
DateTime utcNow = DateTime.UtcNow; DateTime utcNow = DateTime.UtcNow;

View file

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

View file

@ -2,7 +2,6 @@ using System.Collections.Concurrent;
using CS2_SimpleAdmin.Database; using CS2_SimpleAdmin.Database;
using CS2_SimpleAdmin.Models; using CS2_SimpleAdmin.Models;
using Dapper; using Dapper;
using Microsoft.Extensions.Logging;
using ZLinq; using ZLinq;
namespace CS2_SimpleAdmin.Managers; namespace CS2_SimpleAdmin.Managers;
@ -17,7 +16,6 @@ internal class CacheManager: IDisposable
private HashSet<uint> _cachedIgnoredIps = []; private HashSet<uint> _cachedIgnoredIps = [];
private DateTime _lastUpdateTime = DateTime.MinValue; private DateTime _lastUpdateTime = DateTime.MinValue;
private DateTime? _lastDatabaseTime = null; // Track actual time from database
private bool _isInitialized; private bool _isInitialized;
private bool _disposed; private bool _disposed;
@ -73,7 +71,7 @@ internal class CacheManager: IDisposable
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
{ {
// Optimization: Load IP history and build cache in single pass // Optimization: Load IP history and build cache in single pass
var ipHistory = await connection.QueryAsync<IpHistoryRow>( var ipHistory = await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>(
"SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY steamid, address, used_at DESC"); "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"; var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
@ -84,24 +82,24 @@ internal class CacheManager: IDisposable
foreach (var record in ipHistory) foreach (var record in ipHistory)
{ {
// When we encounter a new steamid, save the previous one // When we encounter a new steamid, save the previous one
if ((ulong)record.Steamid != currentSteamId && currentSteamId != 0) if (record.steamid != currentSteamId && currentSteamId != 0)
{ {
_playerIpsCache[currentSteamId] = currentIpSet; _playerIpsCache[currentSteamId] = currentIpSet;
currentIpSet = new HashSet<IpRecord>(new IpRecordComparer()); currentIpSet = new HashSet<IpRecord>(new IpRecordComparer());
latestIpTimestamps.Clear(); latestIpTimestamps.Clear();
} }
currentSteamId = (ulong)record.Steamid; currentSteamId = record.steamid;
// Only keep the latest timestamp for each IP // Only keep the latest timestamp for each IP
if (!latestIpTimestamps.TryGetValue(record.Address, out var existingTimestamp) || if (!latestIpTimestamps.TryGetValue(record.address, out var existingTimestamp) ||
record.Used_at > existingTimestamp) record.used_at > existingTimestamp)
{ {
latestIpTimestamps[record.Address] = record.Used_at; latestIpTimestamps[record.address] = record.used_at;
currentIpSet.Add(new IpRecord( currentIpSet.Add(new IpRecord(
record.Address, record.address,
record.Used_at, record.used_at,
string.IsNullOrEmpty(record.Name) ? unknownName : record.Name string.IsNullOrEmpty(record.name) ? unknownName : record.name
)); ));
} }
} }
@ -158,20 +156,13 @@ internal class CacheManager: IDisposable
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
IEnumerable<BanRecord> updatedBans; IEnumerable<BanRecord> updatedBans;
// Get current time from database in local timezone (CURRENT_TIMESTAMP uses session timezone, not UTC)
var currentDatabaseTime = await connection.QueryFirstAsync<DateTime>("SELECT CURRENT_TIMESTAMP");
// Optimization: Only get IDs for comparison if we need to check for deletions // Optimization: Only get IDs for comparison if we need to check for deletions
// Most of the time bans are just added/updated, not deleted // Most of the time bans are just added/updated, not deleted
HashSet<int>? allIds = null; HashSet<int>? allIds = null;
if (CS2_SimpleAdmin.Instance.Config.MultiServerMode) if (CS2_SimpleAdmin.Instance.Config.MultiServerMode)
{ {
// Use previous database time or start from far past if first run updatedBans = (await connection.QueryAsync<BanRecord>(
var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue;
// Get recently updated bans by timestamp (using database time to avoid timezone issues)
var updatedBans_Query = (await connection.QueryAsync<BanRecord>(
""" """
SELECT id AS Id, SELECT id AS Id,
player_name AS PlayerName, player_name AS PlayerName,
@ -180,68 +171,33 @@ internal class CacheManager: IDisposable
status AS Status status AS Status
FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC
""", """,
new { lastUpdate = lastCheckTime } new { lastUpdate = _lastUpdateTime }
)).ToList(); ));
// Detect changes: new bans or status changes
var updatedList = new List<BanRecord>();
foreach (var ban in updatedBans_Query)
{
if (!_banCache.TryGetValue(ban.Id, out var cachedBan))
{
// New ban
updatedList.Add(ban);
}
else if (cachedBan.Status != ban.Status)
{
// Status changed
updatedList.Add(ban);
}
}
// Optimization: Only fetch all IDs if there were updates
var updatedList = updatedBans.ToList();
if (updatedList.Count > 0) if (updatedList.Count > 0)
{ {
allIds = (await connection.QueryAsync<int>("SELECT id FROM sa_bans")).ToHashSet(); allIds = (await connection.QueryAsync<int>("SELECT id FROM sa_bans")).ToHashSet();
} }
updatedBans = updatedList; updatedBans = updatedList;
// Update last check time to current database time
_lastDatabaseTime = currentDatabaseTime;
} }
else else
{ {
// Use previous database time or start from far past if first run updatedBans = (await connection.QueryAsync<BanRecord>(
var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue;
// Get recently updated bans for this server by timestamp (using database time to avoid timezone issues)
var updatedBans_Query = (await connection.QueryAsync<BanRecord>(
""" """
SELECT id AS Id, SELECT id AS Id,
player_name AS PlayerName, player_name AS PlayerName,
player_steamid AS PlayerSteamId, player_steamid AS PlayerSteamId,
player_ip AS PlayerIp, player_ip AS PlayerIp,
status AS Status status AS Status
FROM `sa_bans` WHERE server_id = @serverId AND (updated_at > @lastUpdate OR created > @lastUpdate) ORDER BY updated_at DESC FROM `sa_bans` WHERE (updated_at > @lastUpdate OR created > @lastUpdate) AND server_id = @serverId ORDER BY updated_at DESC
""", """,
new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime } new { lastUpdate = _lastUpdateTime, serverId = CS2_SimpleAdmin.ServerId }
)).ToList(); ));
// Detect changes: new bans or status changes
var updatedList = new List<BanRecord>();
foreach (var ban in updatedBans_Query)
{
if (!_banCache.TryGetValue(ban.Id, out var cachedBan))
{
// New ban
updatedList.Add(ban);
}
else if (cachedBan.Status != ban.Status)
{
// Status changed
updatedList.Add(ban);
}
}
// Optimization: Only fetch all IDs if there were updates
var updatedList = updatedBans.ToList();
if (updatedList.Count > 0) if (updatedList.Count > 0)
{ {
allIds = (await connection.QueryAsync<int>( allIds = (await connection.QueryAsync<int>(
@ -250,9 +206,6 @@ internal class CacheManager: IDisposable
)).ToHashSet(); )).ToHashSet();
} }
updatedBans = updatedList; updatedBans = updatedList;
// Update last check time to current database time
_lastDatabaseTime = currentDatabaseTime;
} }
// Optimization: Only process deletions if we have the full ID list // Optimization: Only process deletions if we have the full ID list
@ -272,34 +225,34 @@ internal class CacheManager: IDisposable
if (string.IsNullOrWhiteSpace(ban.PlayerIp) || if (string.IsNullOrWhiteSpace(ban.PlayerIp) ||
!IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) || !IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) ||
!_ipIndex.TryGetValue(ipUInt, out var ipBans)) !_ipIndex.TryGetValue(ipUInt, out var ipBans)) continue;
continue; {
ipBans.RemoveAll(b => b.Id == id);
ipBans.RemoveAll(b => b.Id == id); if (ipBans.Count == 0)
if (ipBans.Count == 0) _ipIndex.TryRemove(ipUInt, out _);
_ipIndex.TryRemove(ipUInt, out _); }
} }
} }
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
{ {
var ipHistory = (await connection.QueryAsync<IpHistoryRow>( var ipHistory = (await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>(
"SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300", "SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300",
new { lastUpdate = _lastUpdateTime })); 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<IpRecord>( var ipSet = new HashSet<IpRecord>(
group group
.GroupBy(x => x.Address) .GroupBy(x => x.address)
.Select(g => .Select(g =>
{ {
var latest = g.MaxBy(x => x.Used_at); var latest = g.MaxBy(x => x.used_at);
return new IpRecord( return new IpRecord(
g.Key, g.Key,
latest.Used_at, latest.used_at,
!string.IsNullOrEmpty(latest.Name) !string.IsNullOrEmpty(latest.name)
? latest.Name ? latest.name
: CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown" : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"
); );
}), }),
@ -307,7 +260,7 @@ internal class CacheManager: IDisposable
); );
_playerIpsCache.AddOrUpdate( _playerIpsCache.AddOrUpdate(
(ulong)group.Key, group.Key,
_ => ipSet, _ => ipSet,
(_, existingSet) => (_, existingSet) =>
{ {
@ -323,19 +276,16 @@ internal class CacheManager: IDisposable
} }
// Update cache with new/modified bans // Update cache with new/modified bans
var needsRebuild = false; var hasUpdates = false;
foreach (var ban in updatedBans) 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); _banCache.AddOrUpdate(ban.Id, ban, (_, _) => ban);
hasUpdates = true;
} }
// Rebuild indexes if there were updates or status changes // Always rebuild indexes if there were any updates
if (updatedBans.Any() || needsRebuild) // This ensures status changes (ACTIVE -> UNBANNED) are reflected
if (hasUpdates)
{ {
RebuildIndexes(); RebuildIndexes();
} }
@ -486,41 +436,32 @@ internal class CacheManager: IDisposable
record = steamRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); record = steamRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (record != null) if (record != null)
{ {
// Double-check the ban is still active in cache (handle race conditions) if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
if (_banCache.TryGetValue(record.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE) (!record.PlayerSteamId.HasValue))
{ {
if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) || _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
(!record.PlayerSteamId.HasValue))
{
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
}
return true;
} }
return true;
} }
} }
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress)) if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0)
return false; return false;
if (string.IsNullOrEmpty(ipAddress) || if (string.IsNullOrEmpty(ipAddress) ||
!IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt) || !IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt) ||
_cachedIgnoredIps.Contains(ipUInt) || _cachedIgnoredIps.Contains(ipUInt) ||
!_ipIndex.TryGetValue(ipUInt, out var ipRecords)) return false; !_ipIndex.TryGetValue(ipUInt, out var ipRecords)) return false;
record = ipRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); record = ipRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (record == null) return false; 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)) || if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
(!record.PlayerSteamId.HasValue && steamId.HasValue)) (!record.PlayerSteamId.HasValue && steamId.HasValue))
{ {
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
} }
return true; return true;
} }
@ -606,100 +547,52 @@ internal class CacheManager: IDisposable
var activeBan = steamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); var activeBan = steamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
if (activeBan != null) if (activeBan != null)
{ {
// Double-check the ban is still active in cache (handle race conditions) if (string.IsNullOrEmpty(activeBan.PlayerName) || string.IsNullOrEmpty(activeBan.PlayerIp))
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)); _ = 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; return true;
} }
} }
// Multiaccount ban - check if this player used any ip where other banned accounts are connected if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0)
// Search sa_players_ips for all accounts sharing the same ips as current player
if (!CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
return false; return false;
if (!_playerIpsCache.TryGetValue(steamId, out var playerIps)) if (!_playerIpsCache.TryGetValue(steamId, out var ipData))
return false; return false;
// For each ip the player used (current or historical) var cutoff = Time.ActualDateTime().AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans);
foreach (var playerIpRecord in playerIps) var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
if (ipAddress != null && IpHelper.TryConvertIpToUint(ipAddress, out var ipAsUint))
{ {
// Search sa_players_ips for other accounts using this same ip (as uint) if (!_cachedIgnoredIps.Contains(ipAsUint))
foreach (var (otherSteamId, otherIpSet) in _playerIpsCache)
{ {
if (otherSteamId == steamId) ipData.Add(new IpRecord(ipAsUint, Time.ActualDateTime().AddSeconds(-2), unknownName));
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; return false;
} }

View file

@ -1,4 +1,4 @@
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities;
using Dapper; using Dapper;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -12,8 +12,54 @@ namespace CS2_SimpleAdmin.Managers;
public class PermissionManager(IDatabaseProvider? databaseProvider) public class PermissionManager(IDatabaseProvider? databaseProvider)
{ {
// Unused for now
//public static readonly ConcurrentDictionary<string, ConcurrentBag<string>> _adminCache = new ConcurrentDictionary<string, ConcurrentBag<string>>();
// public static readonly ConcurrentDictionary<SteamID, DateTime?> AdminCache = new();
public static readonly ConcurrentDictionary<SteamID, (DateTime? ExpirationTime, List<string> Flags)> AdminCache = new(); public static readonly ConcurrentDictionary<SteamID, (DateTime? ExpirationTime, List<string> Flags)> AdminCache = new();
/*
public async Task<List<(List<string>, 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<dynamic>? activeFlags = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, CurrentTime = now, serverid = CS2_SimpleAdmin.ServerId }))?.ToList();
if (activeFlags == null)
{
return new List<(List<string>, int)>();
}
List<(List<string>, int)> filteredFlagsWithImmunity = [];
foreach (dynamic flags in activeFlags)
{
if (flags is not IDictionary<string, object> 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;
}
*/
/// <summary> /// <summary>
/// Retrieves all players' flags and associated data asynchronously. /// Retrieves all players' flags and associated data asynchronously.
/// </summary> /// </summary>
@ -54,7 +100,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
string playerName = g.Key.playerName as string ?? string.Empty; string playerName = g.Key.playerName as string ?? string.Empty;
// Dapper returns string here, not dynamic // tutaj zakładamy, że Dapper zwraca już string (nie dynamic)
var flags = g.Select(r => r.flag as string ?? string.Empty) var flags = g.Select(r => r.flag as string ?? string.Empty)
.Distinct() .Distinct()
.ToList(); .ToList();
@ -72,6 +118,72 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
} }
} }
/*
public async Task<Dictionary<int, Tuple<List<string>, List<Tuple<string, DateTime?>>, 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<int>(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<int, Tuple<List<string>, List<Tuple<string, DateTime?>>, int>>();
foreach (var groupId in groupIds)
{
groupInfoDictionary[groupId] = new Tuple<List<string>, List<Tuple<string, DateTime?>>, 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<string, DateTime?>(playerSteamid, ends));
groupInfoDictionary[groupId] = new Tuple<List<string>, List<Tuple<string, DateTime?>>, int>(groupInfoDictionary[groupId].Item1, groupInfoDictionary[groupId].Item2, immunity);
}
return groupInfoDictionary;
}
catch { }
return [];
}
*/
/// <summary> /// <summary>
/// Retrieves all groups' data including flags and immunity asynchronously. /// Retrieves all groups' data including flags and immunity asynchronously.
/// </summary> /// </summary>
@ -80,9 +192,13 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
{ {
if (databaseProvider == null) return []; if (databaseProvider == null) return [];
await using var connection = await databaseProvider.CreateConnectionAsync();
;
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); // var sql = "SELECT group_id FROM sa_groups_servers WHERE (server_id = @serverid OR server_id IS NULL)";
// var groupDataSql = connection.Query<int>(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList();
var sql = databaseProvider.GetGroupsQuery(); var sql = databaseProvider.GetGroupsQuery();
var groupData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); var groupData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList();
if (groupData.Count == 0) if (groupData.Count == 0)
@ -97,9 +213,11 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var flag = (string)row.flag; var flag = (string)row.flag;
var immunity = (int)row.immunity; var immunity = (int)row.immunity;
// Check if the group name already exists in the dictionary
if (!groupInfoDictionary.TryGetValue(groupName, out (List<string>, int) value)) if (!groupInfoDictionary.TryGetValue(groupName, out (List<string>, int) value))
{ {
value = ([], immunity); value = ([], immunity);
// If it doesn't exist, add a new entry with an empty list of flags and immunity
groupInfoDictionary[groupName] = value; groupInfoDictionary[groupName] = value;
} }
@ -120,7 +238,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// Creates a JSON file containing groups data asynchronously. /// Creates a JSON file containing groups data asynchronously.
/// </summary> /// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")] [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
public async Task CreateGroupsJsonFile() public async Task CrateGroupsJsonFile()
{ {
var groupsData = await GetAllGroupsData(); var groupsData = await GetAllGroupsData();
var jsonData = new Dictionary<string, object>(); var jsonData = new Dictionary<string, object>();
@ -139,7 +257,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
{ {
WriteIndented = true, WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}; };
var json = JsonSerializer.Serialize(jsonData, options); var json = JsonSerializer.Serialize(jsonData, options);
@ -147,6 +265,66 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
} }
/*
public async Task GiveAllGroupsFlags()
{
Dictionary<int, Tuple<List<string>, List<Tuple<string, DateTime?>>, 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<string>, int, DateTime?)> allPlayers = await GetAllPlayersFlags();
foreach (var record in allPlayers)
{
string steamIdStr = record.Item1;
List<string> 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);
}
}
}
*/
/// <summary> /// <summary>
/// Creates a JSON file containing admins data asynchronously. /// Creates a JSON file containing admins data asynchronously.
/// </summary> /// </summary>
@ -158,12 +336,25 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
.Where(player => SteamID.TryParse(player.identity.ToString(), out _)) .Where(player => SteamID.TryParse(player.identity.ToString(), out _))
.ToList(); .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 var jsonData = validPlayers
.GroupBy(player => player.name) .GroupBy(player => player.name) // Group by player name
.ToDictionary( .ToDictionary(
group => group.Key, group => group.Key, // Use the player name as key
object (group) => object (group) =>
{ {
// Consolidate data for players with same name
var consolidatedData = group.Aggregate( var consolidatedData = group.Aggregate(
new new
{ {
@ -174,13 +365,16 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
}, },
(acc, player) => (acc, player) =>
{ {
// Merge identities
if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString())) if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString()))
{ {
acc = acc with { identity = 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) }; acc = acc with { immunity = Math.Max(acc.immunity, player.immunity) };
// Combine flags and groups
acc = acc with acc = acc with
{ {
flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(), flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(),
@ -189,12 +383,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
return acc; return acc;
}); });
Server.NextWorldUpdate(() => Server.NextWorldUpdate(() =>
{ {
var keysToRemove = new List<SteamID>(); var keysToRemove = new List<SteamID>();
foreach (var steamId in AdminCache.Keys.ToList()) foreach (var steamId in AdminCache.Keys.ToList())
{ {
var data = AdminManager.GetPlayerAdminData(steamId); var data = AdminManager.GetPlayerAdminData(steamId);
if (data != null) if (data != null)
@ -228,9 +422,40 @@ 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; return consolidatedData;
}); });
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
{ {
WriteIndented = true, WriteIndented = true,
@ -239,7 +464,10 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var json = JsonSerializer.Serialize(jsonData, options); var json = JsonSerializer.Serialize(jsonData, options);
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json"); var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
//await File.WriteAllTextAsync(CS2_SimpleAdmin.Instance.ModuleDirectory + "/data/admins.json", json);
} }
/// <summary> /// <summary>
@ -252,6 +480,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (databaseProvider == null) return; if (databaseProvider == null) return;
if (string.IsNullOrEmpty(playerSteamId)) return; if (string.IsNullOrEmpty(playerSteamId)) return;
//_adminCache.TryRemove(playerSteamId, out _);
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); await using var connection = await databaseProvider.CreateConnectionAsync();
@ -280,12 +510,18 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return; if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return;
var now = Time.ActualDateTime(); var now = Time.ActualDateTime();
DateTime? futureTime = time != 0 ? now.AddMinutes(time) : null; DateTime? futureTime;
if (time != 0)
futureTime = now.AddMinutes(time);
else
futureTime = null;
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); await using var connection = await databaseProvider.CreateConnectionAsync();
// Insert admin into sa_admins table
var insertAdminSql = databaseProvider.GetAddAdminQuery(); var insertAdminSql = databaseProvider.GetAddAdminQuery();
var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new
{ {
@ -297,8 +533,28 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId
}); });
// Insert flags into sa_admins_flags table
foreach (var flag in flagsList) foreach (var flag in flagsList)
{ {
// if (flag.StartsWith($"#"))
// {
// // const string sql = "SELECT id FROM `sa_groups` WHERE name = @groupName";
// // var groupId = await connection.QuerySingleOrDefaultAsync<int?>(sql, new { groupName = flag });
//
// var sql = databaseProvider.GetGroupIdByNameQuery();
// var groupId = await connection.QuerySingleOrDefaultAsync<int?>(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(); var insertFlagsSql = databaseProvider.GetAddAdminFlagsQuery();
await connection.ExecuteAsync(insertFlagsSql, new await connection.ExecuteAsync(insertFlagsSql, new
{ {
@ -331,10 +587,10 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return; if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return;
await using var connection = await databaseProvider.CreateConnectionAsync();
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); // Insert group into sa_groups table
var insertGroup = databaseProvider.GetAddGroupQuery(); var insertGroup = databaseProvider.GetAddGroupQuery();
var groupId = await connection.ExecuteScalarAsync<int>(insertGroup, new var groupId = await connection.ExecuteScalarAsync<int>(insertGroup, new
{ {
@ -342,9 +598,11 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
immunity immunity
}); });
// Insert flags into sa_groups_flags table
foreach (var flag in flagsList) foreach (var flag in flagsList)
{ {
var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery(); var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery();
await connection.ExecuteAsync(insertFlagsSql, new await connection.ExecuteAsync(insertFlagsSql, new
{ {
groupId, groupId,
@ -358,6 +616,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
{ {
CS2_SimpleAdmin.Instance.ReloadAdmins(null); CS2_SimpleAdmin.Instance.ReloadAdmins(null);
}); });
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -375,9 +634,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (string.IsNullOrEmpty(groupName)) return; if (string.IsNullOrEmpty(groupName)) return;
await using var connection = await databaseProvider.CreateConnectionAsync();
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync();
var sql = databaseProvider.GetDeleteGroupQuery(); var sql = databaseProvider.GetDeleteGroupQuery();
await connection.ExecuteAsync(sql, new { groupName }); await connection.ExecuteAsync(sql, new { groupName });
} }
@ -406,4 +665,4 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
CS2_SimpleAdmin._logger?.LogCritical("Unable to remove expired admins"); CS2_SimpleAdmin._logger?.LogCritical("Unable to remove expired admins");
} }
} }
} }

View file

@ -13,7 +13,7 @@ namespace CS2_SimpleAdmin.Managers;
internal class PlayerManager internal class PlayerManager
{ {
private readonly SemaphoreSlim _loadPlayerSemaphore = new(6); private readonly SemaphoreSlim _loadPlayerSemaphore = new(5);
private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config; private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config;
/// <summary> /// <summary>
@ -52,40 +52,81 @@ internal class PlayerManager
{ {
await _loadPlayerSemaphore.WaitAsync(); await _loadPlayerSemaphore.WaitAsync();
// Save ip address before ban check if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId))
await SavePlayerIpAddress(steamId, playerName, ipAddress);
// Always check bans first, regardless of PlayersInfo state
var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch
{ {
0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null), var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch
_ => 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(() =>
{ {
CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Executing kick for {playerName}"); 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null),
Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); _ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp
}); ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId,
ipAddress)
: CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress)
};
return; // 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;
}
} }
if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId)) if (fullConnect)
{ {
var playerInfo = new PlayerInfo(userId, slot, new SteamID(steamId), playerName, ipAddress); var playerInfo = new PlayerInfo(userId, slot, new SteamID(steamId), playerName, ipAddress);
CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo; CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo;
if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null) await Server.NextWorldUpdateAsync(() =>
{ {
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 = playerInfo.AccountsAssociated =
CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable() CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable()
.Select(x => (x.SteamId, x.PlayerName)).ToList() ?? []; .Select(x => (x.SteamId, x.PlayerName)).ToList() ?? [];
@ -172,7 +213,7 @@ internal class PlayerManager
AdminManager.PlayerHasPermissions( AdminManager.PlayerHasPermissions(
new SteamID(p.SteamID), new SteamID(p.SteamID),
"@css/ban")) && "@css/ban")) &&
p.Connected == PlayerConnectedState.Connected && p.Connected == PlayerConnectedState.PlayerConnected &&
!CS2_SimpleAdmin.AdminDisabledJoinComms !CS2_SimpleAdmin.AdminDisabledJoinComms
.Contains(p.SteamID))) .Contains(p.SteamID)))
{ {
@ -219,45 +260,12 @@ internal class PlayerManager
_loadPlayerSemaphore.Release(); _loadPlayerSemaphore.Release();
} }
}); });
if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name)) if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name))
{ {
player.Rename(name); player.Rename(name);
} }
} }
/// <summary>
/// Saves player's IP address to the database for multi-account detection.
/// This is called before ban checks to ensure IP is recorded even if player is banned.
/// </summary>
private async Task SavePlayerIpAddress(ulong steamId, string playerName, string? ipAddress)
{
if (!_config.OtherSettings.CheckMultiAccountsByIp || ipAddress == null || CS2_SimpleAdmin.DatabaseProvider == null)
return;
try
{
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
var steamId64 = steamId;
var ipUint = IpHelper.IpToUint(ipAddress);
var upsertQuery = CS2_SimpleAdmin.DatabaseProvider.GetUpsertPlayerIpQuery();
await connection.ExecuteAsync(upsertQuery, new
{
SteamID = steamId64,
playerName,
IPAddress = ipUint
});
}
catch (Exception ex)
{
CS2_SimpleAdmin._logger?.LogError(
$"Unable to save ip address for {playerName} ({ipAddress}): {ex.Message}");
}
}
/// <summary> /// <summary>
/// Periodically checks the status of online players and applies timers for speed, gravity, /// Periodically checks the status of online players and applies timers for speed, gravity,
/// and penalty expiration validation. /// and penalty expiration validation.
@ -275,16 +283,6 @@ internal class PlayerManager
/// </remarks> /// </remarks>
public void CheckPlayersTimer() 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, () => CS2_SimpleAdmin.Instance.PlayersTimer = CS2_SimpleAdmin.Instance.AddTimer(61.0f, () =>
{ {
#if DEBUG #if DEBUG
@ -295,6 +293,9 @@ internal class PlayerManager
// Optimization: Get players once and avoid allocating anonymous types // Optimization: Get players once and avoid allocating anonymous types
var validPlayers = Helper.GetValidPlayers(); var validPlayers = Helper.GetValidPlayers();
if (validPlayers.Count == 0)
return;
// Use ValueTuple instead of anonymous type - better performance and less allocations // 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); var tempPlayers = new List<(string PlayerName, ulong SteamID, string? IpAddress, int? UserId, int Slot)>(validPlayers.Count);
foreach (var p in validPlayers) foreach (var p in validPlayers)
@ -369,12 +370,7 @@ internal class PlayerManager
foreach (var player in bannedPlayers) foreach (var player in bannedPlayers)
{ {
if (!player.UserId.HasValue) continue; if (!player.UserId.HasValue) continue;
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() => Helper.KickPlayer((int)player.UserId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED));
{
if (Helper.GetPlayerFromSteamid64(player.SteamID) != null)
Helper.KickPlayer((int)player.UserId,
NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED);
});
} }
} }

View file

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

View file

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

View file

@ -1,6 +1,5 @@
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Translations;
using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities;
using CounterStrikeSharp.API.Modules.Entities.Constants; using CounterStrikeSharp.API.Modules.Entities.Constants;
@ -13,33 +12,32 @@ public abstract class BasicMenu
{ {
/// <summary> /// <summary>
/// Initializes all menus in the system by registering them with the MenuManager. /// Initializes all menus in the system by registering them with the MenuManager.
/// Register with translation keys instead of static names - translation happens per-player.
/// </summary> /// </summary>
public static void Initialize() public static void Initialize()
{ {
var manager = MenuManager.Instance; var manager = MenuManager.Instance;
// Players category menus - using translation keys // Players category menus
manager.RegisterMenu("players", "slap", "sa_slap", CreateSlapMenu, "@css/slay"); manager.RegisterMenu("players", "slap", "Slap Player", CreateSlapMenu, "@css/slay");
manager.RegisterMenu("players", "slay", "sa_slay", CreateSlayMenu, "@css/slay"); manager.RegisterMenu("players", "slay", "Slay Player", CreateSlayMenu, "@css/slay");
manager.RegisterMenu("players", "kick", "sa_kick", CreateKickMenu, "@css/kick"); manager.RegisterMenu("players", "kick", "Kick Player", CreateKickMenu, "@css/kick");
manager.RegisterMenu("players", "warn", "sa_warn", CreateWarnMenu, "@css/kick"); manager.RegisterMenu("players", "warn", "Warn Player", CreateWarnMenu, "@css/kick");
manager.RegisterMenu("players", "ban", "sa_ban", CreateBanMenu, "@css/ban"); manager.RegisterMenu("players", "ban", "Ban Player", CreateBanMenu, "@css/ban");
manager.RegisterMenu("players", "gag", "sa_gag", CreateGagMenu, "@css/chat"); manager.RegisterMenu("players", "gag", "Gag Player", CreateGagMenu, "@css/chat");
manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat"); manager.RegisterMenu("players", "mute", "Mute Player", CreateMuteMenu, "@css/chat");
manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat"); manager.RegisterMenu("players", "silence", "Silence Player", CreateSilenceMenu, "@css/chat");
manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick"); manager.RegisterMenu("players", "team", "Force Team", CreateForceTeamMenu, "@css/kick");
// Server category menus - using translation keys // Server category menus
manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root"); manager.RegisterMenu("server", "plugins", "Manage Plugins", CreatePluginsMenu, "@css/root");
manager.RegisterMenu("server", "changemap", "sa_changemap", CreateChangeMapMenu, "@css/changemap"); manager.RegisterMenu("server", "changemap", "Change Map", CreateChangeMapMenu, "@css/changemap");
manager.RegisterMenu("server", "restart", "sa_restart_game", CreateRestartGameMenu, "@css/generic"); manager.RegisterMenu("server", "restart", "Restart Game", CreateRestartGameMenu, "@css/generic");
manager.RegisterMenu("server", "custom", "sa_menu_custom_commands", CreateCustomCommandsMenu, "@css/generic"); manager.RegisterMenu("server", "custom", "Custom Commands", CreateCustomCommandsMenu, "@css/generic");
// Admin category menus - using translation keys // Admin category menus
manager.RegisterMenu("admin", "add", "sa_admin_add", CreateAddAdminMenu, "@css/root"); manager.RegisterMenu("admin", "add", "Add Admin", CreateAddAdminMenu, "@css/root");
manager.RegisterMenu("admin", "remove", "sa_admin_remove", CreateRemoveAdminMenu, "@css/root"); manager.RegisterMenu("admin", "remove", "Remove Admin", CreateRemoveAdminMenu, "@css/root");
manager.RegisterMenu("admin", "reload", "sa_admin_reload", CreateReloadAdminsMenu, "@css/root"); manager.RegisterMenu("admin", "reload", "Reload Admins", CreateReloadAdminsMenu, "@css/root");
} }
/// <summary> /// <summary>
@ -51,15 +49,14 @@ public abstract class BasicMenu
private static MenuBuilder CreateSlapMenu(CCSPlayerController admin) private static MenuBuilder CreateSlapMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var slapMenu = new MenuBuilder("sa_slap", admin, localizer); var slapMenu = new MenuBuilder(localizer?["sa_slap"] ?? "Slap Player");
var players = Helper.GetValidPlayers().Where(admin.CanTarget); var players = Helper.GetValidPlayers().Where(admin.CanTarget);
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; slapMenu.AddSubMenu(playerName, () => CreateSlapDamageMenu(admin, player));
slapMenu.AddSubMenu(playerName, () => CreateSlapDamageMenu(admin, capturedPlayer));
} }
return slapMenu.WithBackButton(); return slapMenu.WithBackButton();
@ -73,25 +70,18 @@ public abstract class BasicMenu
/// <returns>A MenuBuilder instance for the slap damage menu.</returns> /// <returns>A MenuBuilder instance for the slap damage menu.</returns>
private static MenuBuilder CreateSlapDamageMenu(CCSPlayerController admin, CCSPlayerController target) private static MenuBuilder CreateSlapDamageMenu(CCSPlayerController admin, CCSPlayerController target)
{ {
var localizer = CS2_SimpleAdmin._localizer; var slapDamageMenu = new MenuBuilder($"Slap: {target.PlayerName}");
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 }; var damages = new[] { 0, 1, 5, 10, 50, 100 };
foreach (var damage in damages) foreach (var damage in damages)
{ {
slapDamageMenu.AddOption($"{damage} HP", currentAdmin => slapDamageMenu.AddOption($"{damage} HP", _ =>
{ {
if (target.IsValid) if (target.IsValid)
{ {
CS2_SimpleAdmin.Slap(currentAdmin, target, damage); CS2_SimpleAdmin.Slap(admin, target, damage);
// Reopen the same menu (not create new one) to keep back button working // Keep menu open for consecutive slaps
slapDamageMenu.OpenMenu(currentAdmin); CreateSlapDamageMenu(admin, target).OpenMenu(admin);
} }
}); });
} }
@ -107,19 +97,18 @@ public abstract class BasicMenu
private static MenuBuilder CreateSlayMenu(CCSPlayerController admin) private static MenuBuilder CreateSlayMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var slayMenu = new MenuBuilder("sa_slay", admin, localizer); var slayMenu = new MenuBuilder(localizer?["sa_slay"] ?? "Slay Player");
var players = Helper.GetValidPlayers().Where(admin.CanTarget); var players = Helper.GetValidPlayers().Where(admin.CanTarget);
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName;
slayMenu.AddOption(playerName, _ => slayMenu.AddOption(playerName, _ =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Slay(admin, capturedPlayer); CS2_SimpleAdmin.Slay(admin, player);
} }
}); });
} }
@ -135,20 +124,19 @@ public abstract class BasicMenu
private static MenuBuilder CreateKickMenu(CCSPlayerController admin) private static MenuBuilder CreateKickMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var kickMenu = new MenuBuilder("sa_kick", admin, localizer); var kickMenu = new MenuBuilder(localizer?["sa_kick"] ?? "Kick Player");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; kickMenu.AddSubMenu(playerName, () => CreateReasonMenu(admin, player, "Kick", PenaltyType.Kick,
kickMenu.AddSubMenu(playerName, () => CreateReasonMenu(admin, capturedPlayer, "Kick", PenaltyType.Kick,
(_, _, reason) => (_, _, reason) =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.Kick(admin, capturedPlayer, reason, admin.PlayerName); CS2_SimpleAdmin.Instance.Kick(admin, player, reason, admin.PlayerName);
} }
})); }));
} }
@ -164,21 +152,20 @@ public abstract class BasicMenu
private static MenuBuilder CreateWarnMenu(CCSPlayerController admin) private static MenuBuilder CreateWarnMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var warnMenu = new MenuBuilder("sa_warn", admin, localizer); var warnMenu = new MenuBuilder(localizer?["sa_warn"] ?? "Warn Player");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; warnMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Warn",
warnMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Warn", (_, _, duration) => CreateReasonMenu(admin, player, "Warn", PenaltyType.Warn,
(_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Warn", PenaltyType.Warn,
(_, _, reason) => (_, _, reason) =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.Warn(admin, capturedPlayer, duration, reason, admin.PlayerName); CS2_SimpleAdmin.Instance.Warn(admin, player, duration, reason, admin.PlayerName);
} }
}))); })));
} }
@ -194,21 +181,20 @@ public abstract class BasicMenu
private static MenuBuilder CreateBanMenu(CCSPlayerController admin) private static MenuBuilder CreateBanMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var banMenu = new MenuBuilder("sa_ban", admin, localizer); var banMenu = new MenuBuilder(localizer?["sa_ban"] ?? "Ban Player");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; banMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Ban",
banMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Ban", (_, _, duration) => CreateReasonMenu(admin, player, "Ban", PenaltyType.Ban,
(_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Ban", PenaltyType.Ban,
(_, _, reason) => (_, _, reason) =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.Ban(admin, capturedPlayer, duration, reason, admin.PlayerName); CS2_SimpleAdmin.Instance.Ban(admin, player, duration, reason, admin.PlayerName);
} }
}))); })));
} }
@ -224,21 +210,20 @@ public abstract class BasicMenu
private static MenuBuilder CreateGagMenu(CCSPlayerController admin) private static MenuBuilder CreateGagMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var gagMenu = new MenuBuilder("sa_gag", admin, localizer); var gagMenu = new MenuBuilder(localizer?["sa_gag"] ?? "Gag Player");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; gagMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Gag",
gagMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Gag", (_, _, duration) => CreateReasonMenu(admin, player, "Gag", PenaltyType.Gag,
(_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Gag", PenaltyType.Gag,
(_, _, reason) => (_, _, reason) =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.Gag(admin, capturedPlayer, duration, reason); CS2_SimpleAdmin.Instance.Gag(admin, player, duration, reason);
} }
}))); })));
} }
@ -254,21 +239,20 @@ public abstract class BasicMenu
private static MenuBuilder CreateMuteMenu(CCSPlayerController admin) private static MenuBuilder CreateMuteMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var muteMenu = new MenuBuilder("sa_mute", admin, localizer); var muteMenu = new MenuBuilder(localizer?["sa_mute"] ?? "Mute Player");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; muteMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Mute",
muteMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Mute", (_, _, duration) => CreateReasonMenu(admin, player, "Mute", PenaltyType.Mute,
(_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Mute", PenaltyType.Mute,
(_, _, reason) => (_, _, reason) =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.Mute(admin, capturedPlayer, duration, reason); CS2_SimpleAdmin.Instance.Mute(admin, player, duration, reason);
} }
}))); })));
} }
@ -284,21 +268,20 @@ public abstract class BasicMenu
private static MenuBuilder CreateSilenceMenu(CCSPlayerController admin) private static MenuBuilder CreateSilenceMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var silenceMenu = new MenuBuilder("sa_silence", admin, localizer); var silenceMenu = new MenuBuilder(localizer?["sa_silence"] ?? "Silence Player");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; silenceMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, player, "Silence",
silenceMenu.AddSubMenu(playerName, () => CreateDurationMenu(admin, capturedPlayer, "Silence", (_, _, duration) => CreateReasonMenu(admin, player, "Silence", PenaltyType.Silence,
(_, _, duration) => CreateReasonMenu(admin, capturedPlayer, "Silence", PenaltyType.Silence,
(_, _, reason) => (_, _, reason) =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.Silence(admin, capturedPlayer, duration, reason); CS2_SimpleAdmin.Instance.Silence(admin, player, duration, reason);
} }
}))); })));
} }
@ -314,15 +297,14 @@ public abstract class BasicMenu
private static MenuBuilder CreateForceTeamMenu(CCSPlayerController admin) private static MenuBuilder CreateForceTeamMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var teamMenu = new MenuBuilder("sa_team_force", admin, localizer); var teamMenu = new MenuBuilder(localizer?["sa_team_force"] ?? "Force Team");
var players = Helper.GetValidPlayers().Where(p => admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; teamMenu.AddSubMenu(playerName, () => CreateTeamSelectionMenu(admin, player));
teamMenu.AddSubMenu(playerName, () => CreateTeamSelectionMenu(admin, capturedPlayer));
} }
return teamMenu.WithBackButton(); return teamMenu.WithBackButton();
@ -337,32 +319,14 @@ public abstract class BasicMenu
private static MenuBuilder CreateTeamSelectionMenu(CCSPlayerController admin, CCSPlayerController target) private static MenuBuilder CreateTeamSelectionMenu(CCSPlayerController admin, CCSPlayerController target)
{ {
var localizer = CS2_SimpleAdmin._localizer; 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[] var teams = new[]
{ {
(ctName, "ct", CsTeam.CounterTerrorist), (localizer?["sa_team_ct"] ?? "CT", "ct", CsTeam.CounterTerrorist),
(tName, "t", CsTeam.Terrorist), (localizer?["sa_team_t"] ?? "T", "t", CsTeam.Terrorist),
(swapName, "swap", CsTeam.Spectator), (localizer?["sa_team_swap"] ?? "Swap", "swap", CsTeam.Spectator),
(specName, "spec", CsTeam.Spectator) (localizer?["sa_team_spec"] ?? "Spec", "spec", CsTeam.Spectator)
}; };
foreach (var (name, teamName, teamNum) in teams) foreach (var (name, teamName, teamNum) in teams)
@ -387,7 +351,7 @@ public abstract class BasicMenu
private static MenuBuilder CreatePluginsMenu(CCSPlayerController admin) private static MenuBuilder CreatePluginsMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var pluginsMenu = new MenuBuilder("sa_menu_pluginsmanager_title", admin, localizer); var pluginsMenu = new MenuBuilder(localizer?["sa_menu_pluginsmanager_title"] ?? "Manage Plugins");
pluginsMenu.AddOption("Open Plugins Manager", _ => pluginsMenu.AddOption("Open Plugins Manager", _ =>
{ {
@ -405,7 +369,7 @@ public abstract class BasicMenu
private static MenuBuilder CreateChangeMapMenu(CCSPlayerController admin) private static MenuBuilder CreateChangeMapMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var mapMenu = new MenuBuilder("sa_changemap", admin, localizer); var mapMenu = new MenuBuilder(localizer?["sa_changemap"] ?? "Change Map");
// Add default maps // Add default maps
var maps = CS2_SimpleAdmin.Instance.Config.DefaultMaps; var maps = CS2_SimpleAdmin.Instance.Config.DefaultMaps;
@ -438,7 +402,7 @@ public abstract class BasicMenu
private static MenuBuilder CreateRestartGameMenu(CCSPlayerController admin) private static MenuBuilder CreateRestartGameMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var restartMenu = new MenuBuilder("sa_restart_game", admin, localizer); var restartMenu = new MenuBuilder(localizer?["sa_restart_game"] ?? "Restart Game");
restartMenu.AddOption("Restart Round", _ => restartMenu.AddOption("Restart Round", _ =>
{ {
@ -456,7 +420,7 @@ public abstract class BasicMenu
private static MenuBuilder CreateCustomCommandsMenu(CCSPlayerController admin) private static MenuBuilder CreateCustomCommandsMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var customMenu = new MenuBuilder("sa_menu_custom_commands", admin, localizer); var customMenu = new MenuBuilder(localizer?["sa_menu_custom_commands"] ?? "Custom Commands");
var customCommands = CS2_SimpleAdmin.Instance.Config.CustomServerCommands; var customCommands = CS2_SimpleAdmin.Instance.Config.CustomServerCommands;
@ -491,15 +455,14 @@ public abstract class BasicMenu
private static MenuBuilder CreateAddAdminMenu(CCSPlayerController admin) private static MenuBuilder CreateAddAdminMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var addAdminMenu = new MenuBuilder("sa_admin_add", admin, localizer); var addAdminMenu = new MenuBuilder(localizer?["sa_admin_add"] ?? "Add Admin");
var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p));
foreach (var player in players) foreach (var player in players)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName; addAdminMenu.AddSubMenu(playerName, () => CreateAdminFlagsMenu(admin, player));
addAdminMenu.AddSubMenu(playerName, () => CreateAdminFlagsMenu(admin, capturedPlayer));
} }
return addAdminMenu.WithBackButton(); return addAdminMenu.WithBackButton();
@ -513,16 +476,7 @@ public abstract class BasicMenu
/// <returns>A MenuBuilder instance for the admin flags menu.</returns> /// <returns>A MenuBuilder instance for the admin flags menu.</returns>
private static MenuBuilder CreateAdminFlagsMenu(CCSPlayerController admin, CCSPlayerController target) private static MenuBuilder CreateAdminFlagsMenu(CCSPlayerController admin, CCSPlayerController target)
{ {
var localizer = CS2_SimpleAdmin._localizer; var flagsMenu = new MenuBuilder($"Add Admin: {target.PlayerName}");
// 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) foreach (var adminFlag in CS2_SimpleAdmin.Instance.Config.MenuConfigs.AdminFlags)
{ {
@ -547,22 +501,21 @@ public abstract class BasicMenu
private static MenuBuilder CreateRemoveAdminMenu(CCSPlayerController admin) private static MenuBuilder CreateRemoveAdminMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var removeAdminMenu = new MenuBuilder("sa_admin_remove", admin, localizer); var removeAdminMenu = new MenuBuilder(localizer?["sa_admin_remove"] ?? "Remove Admin");
var adminPlayers = Helper.GetValidPlayers().Where(p => var adminPlayers = Helper.GetValidPlayers().Where(p =>
AdminManager.GetPlayerAdminData(p)?.Flags.Count > 0 && AdminManager.GetPlayerAdminData(p)?.Flags.Count > 0 &&
p != admin && p != admin &&
admin.CanTarget(p)); admin.CanTarget(p));
foreach (var player in adminPlayers) foreach (var player in adminPlayers)
{ {
var capturedPlayer = player; // Capture to local variable to avoid closure issue var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName;
var playerName = capturedPlayer.PlayerName.Length > 26 ? capturedPlayer.PlayerName[..26] : capturedPlayer.PlayerName;
removeAdminMenu.AddOption(playerName, _ => removeAdminMenu.AddOption(playerName, _ =>
{ {
if (capturedPlayer.IsValid) if (player.IsValid)
{ {
CS2_SimpleAdmin.Instance.RemoveAdmin(admin, capturedPlayer.SteamID.ToString()); CS2_SimpleAdmin.Instance.RemoveAdmin(admin, player.SteamID.ToString());
} }
}); });
} }
@ -578,7 +531,7 @@ public abstract class BasicMenu
private static MenuBuilder CreateReloadAdminsMenu(CCSPlayerController admin) private static MenuBuilder CreateReloadAdminsMenu(CCSPlayerController admin)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var reloadMenu = new MenuBuilder("sa_admin_reload", admin, localizer); var reloadMenu = new MenuBuilder(localizer?["sa_admin_reload"] ?? "Reload Admins");
reloadMenu.AddOption("Reload Admins", _ => reloadMenu.AddOption("Reload Admins", _ =>
{ {
@ -593,40 +546,20 @@ public abstract class BasicMenu
/// </summary> /// </summary>
/// <param name="admin">The admin player selecting duration.</param> /// <param name="admin">The admin player selecting duration.</param>
/// <param name="player">The target player for the penalty.</param> /// <param name="player">The target player for the penalty.</param>
/// <param name="actionName">The name of the penalty action (e.g., "Kick", "Ban").</param> /// <param name="actionName">The name of the penalty action.</param>
/// <param name="onSelectAction">Callback function that returns the next menu when duration is selected.</param> /// <param name="onSelectAction">Callback action executed when duration is selected.</param>
/// <returns>A MenuBuilder instance for the duration menu.</returns> /// <returns>A MenuBuilder instance for the duration menu.</returns>
private static MenuBuilder CreateDurationMenu(CCSPlayerController admin, CCSPlayerController player, string actionName, private static MenuBuilder CreateDurationMenu(CCSPlayerController admin, CCSPlayerController player, string actionName,
Func<CCSPlayerController, CCSPlayerController, int, MenuBuilder> onSelectAction) Action<CCSPlayerController, CCSPlayerController, int> onSelectAction)
{ {
var localizer = CS2_SimpleAdmin._localizer; var durationMenu = new MenuBuilder($"{actionName} Duration: {player.PlayerName}");
// 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) foreach (var durationItem in CS2_SimpleAdmin.Instance.Config.MenuConfigs.Durations)
{ {
var capturedDuration = durationItem.Duration; // Capture to avoid closure issue durationMenu.AddOption(durationItem.Name, _ =>
durationMenu.AddSubMenu(durationItem.Name, () => onSelectAction(admin, player, capturedDuration)); {
onSelectAction(admin, player, durationItem.Duration);
});
} }
return durationMenu.WithBackButton(); return durationMenu.WithBackButton();
@ -637,36 +570,14 @@ public abstract class BasicMenu
/// </summary> /// </summary>
/// <param name="admin">The admin player selecting reason.</param> /// <param name="admin">The admin player selecting reason.</param>
/// <param name="player">The target player for the penalty.</param> /// <param name="player">The target player for the penalty.</param>
/// <param name="actionName">The name of the penalty action (e.g., "Kick", "Ban").</param> /// <param name="actionName">The name of the penalty action.</param>
/// <param name="penaltyType">The type of penalty to determine which reason list to use.</param> /// <param name="penaltyType">The type of penalty to determine which reason list to use.</param>
/// <param name="onSelectAction">Callback action executed when reason is selected.</param> /// <param name="onSelectAction">Callback action executed when reason is selected.</param>
/// <returns>A MenuBuilder instance for the reason menu.</returns> /// <returns>A MenuBuilder instance for the reason menu.</returns>
private static MenuBuilder CreateReasonMenu(CCSPlayerController admin, CCSPlayerController player, string actionName, private static MenuBuilder CreateReasonMenu(CCSPlayerController admin, CCSPlayerController player, string actionName,
PenaltyType penaltyType, Action<CCSPlayerController, CCSPlayerController, string> onSelectAction) PenaltyType penaltyType, Action<CCSPlayerController, CCSPlayerController, string> onSelectAction)
{ {
var localizer = CS2_SimpleAdmin._localizer; var reasonMenu = new MenuBuilder($"{actionName} Reason: {player.PlayerName}");
// 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 var reasons = penaltyType switch
{ {

View file

@ -8,28 +8,12 @@ public static class DurationMenu
public static void OpenMenu(CCSPlayerController admin, string menuName, CCSPlayerController player, Action<CCSPlayerController, CCSPlayerController, int> onSelectAction) public static void OpenMenu(CCSPlayerController admin, string menuName, CCSPlayerController player, Action<CCSPlayerController, CCSPlayerController, int> onSelectAction)
{ {
var menu = AdminMenu.CreateMenu(menuName); var menu = AdminMenu.CreateMenu(menuName);
if (menu == null) foreach (var durationItem in CS2_SimpleAdmin.Instance.Config.MenuConfigs.Durations)
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)
{ {
var duration = durationItem.Duration; // Capture in local variable menu?.AddMenuOption(durationItem.Name, (_, _) => { onSelectAction(admin, player, durationItem.Duration); });
var name = durationItem.Name;
menu.AddMenuOption(name, (controller, option) =>
{
capturedAction(capturedAdmin, capturedPlayer, duration);
});
} }
AdminMenu.OpenMenu(admin, menu); if (menu != null) AdminMenu.OpenMenu(admin, menu);
} }
public static void OpenMenu(CCSPlayerController admin, string menuName, DisconnectedPlayer player, Action<CCSPlayerController, DisconnectedPlayer, int> onSelectAction) public static void OpenMenu(CCSPlayerController admin, string menuName, DisconnectedPlayer player, Action<CCSPlayerController, DisconnectedPlayer, int> onSelectAction)

View file

@ -49,27 +49,25 @@ public static class ManagePlayersMenu
if (AdminManager.CommandIsOverriden("css_warn") if (AdminManager.CommandIsOverriden("css_warn")
? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_warn")) ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_warn"))
: AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/kick")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/kick"))
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)))); 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))));
if (hasBan) if (hasBan)
options.Add(new ChatMenuOptionData(localizer?["sa_ban"] ?? "Ban", () => 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))));
PlayersMenu.OpenRealPlayersMenu(admin, localizer?["sa_ban"] ?? "Ban", (a, p) =>
DurationMenu.OpenMenu(a, $"{localizer?["sa_ban"] ?? "Ban"}: {p.PlayerName}", p, BanMenu))));
if (hasChat) if (hasChat)
{ {
if (AdminManager.CommandIsOverriden("css_gag") if (AdminManager.CommandIsOverriden("css_gag")
? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_gag")) ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_gag"))
: AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat"))
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)))); 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))));
if (AdminManager.CommandIsOverriden("css_mute") if (AdminManager.CommandIsOverriden("css_mute")
? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_mute")) ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_mute"))
: AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat"))
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)))); 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))));
if (AdminManager.CommandIsOverriden("css_silence") if (AdminManager.CommandIsOverriden("css_silence")
? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_silence")) ? AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), AdminManager.GetPermissionOverrides("css_silence"))
: AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat")) : AdminManager.PlayerHasPermissions(new SteamID(admin.SteamID), "@css/chat"))
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)))); 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))));
} }
if (AdminManager.CommandIsOverriden("css_team") if (AdminManager.CommandIsOverriden("css_team")
@ -161,9 +159,22 @@ public static class ManagePlayersMenu
{ {
if (player is { IsValid: true }) if (player is { IsValid: true })
Ban(admin, player, duration, reason); Ban(admin, player, duration, reason);
CS2_SimpleAdmin.MenuApi?.CloseMenu(admin); 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) private static void Ban(CCSPlayerController admin, CCSPlayerController player, int duration, string reason)

View file

@ -1,89 +1,28 @@
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Translations;
using Microsoft.Extensions.Localization;
namespace CS2_SimpleAdmin.Menus; namespace CS2_SimpleAdmin.Menus;
public class MenuBuilder public class MenuBuilder(string title)
{ {
private readonly string _title;
private readonly CCSPlayerController? _player;
private readonly IStringLocalizer? _localizer;
private readonly List<MenuOption> _options = []; private readonly List<MenuOption> _options = [];
private MenuBuilder? _parentMenu; private MenuBuilder? _parentMenu;
private Action<CCSPlayerController>? _backAction; private Action<CCSPlayerController>? _backAction;
private Action<CCSPlayerController>? _resetAction; private Action<CCSPlayerController>? _resetAction;
/// <summary>
/// Constructor for player-localized menu with translation key
/// </summary>
public MenuBuilder(string titleKey, CCSPlayerController player, IStringLocalizer? localizer = null)
{
_title = titleKey;
_player = player;
_localizer = localizer ?? CS2_SimpleAdmin._localizer;
}
/// <summary>
/// Constructor for static title (backward compatibility)
/// </summary>
public MenuBuilder(string title)
{
_title = title;
_player = null;
_localizer = null;
}
/// <summary>
/// Gets the localized title for the player
/// </summary>
private string GetLocalizedTitle()
{
if (_player != null && _localizer != null)
{
using (new WithTemporaryCulture(_player.GetLanguage()))
{
return _localizer[_title];
}
}
return _title;
}
/// <summary> /// <summary>
/// Adds a menu option with an action. /// Adds a menu option with an action.
/// </summary> /// </summary>
/// <param name="name">Display name or translation key</param> public MenuBuilder AddOption(string name, Action<CCSPlayerController> action, bool disabled = false, string? permission = null)
/// <param name="action">Action to perform when selected</param>
/// <param name="disabled">Whether the option is disabled</param>
/// <param name="permission">Required permission</param>
/// <param name="isTranslationKey">If true, name is a translation key to be localized</param>
public MenuBuilder AddOption(string name, Action<CCSPlayerController> action, bool disabled = false, string? permission = null, bool isTranslationKey = false)
{ {
_options.Add(new MenuOption _options.Add(new MenuOption
{ {
Name = name, Name = name,
Action = action, Action = action,
Disabled = disabled, Disabled = disabled,
Permission = permission, Permission = permission
IsTranslationKey = isTranslationKey
}); });
return this; return this;
} }
/// <summary>
/// Gets the localized name for a menu option
/// </summary>
private string GetLocalizedOptionName(MenuOption option)
{
if (option.IsTranslationKey && _player != null && _localizer != null)
{
using (new WithTemporaryCulture(_player.GetLanguage()))
{
return _localizer[option.Name];
}
}
return option.Name;
}
/// <summary> /// <summary>
/// Adds a menu option that opens a submenu. /// Adds a menu option that opens a submenu.
/// </summary> /// </summary>
@ -160,11 +99,8 @@ public class MenuBuilder
{ {
if (!player.IsValid) return; if (!player.IsValid) return;
// Get localized title
var localizedTitle = GetLocalizedTitle();
// Use MenuManager dependency // Use MenuManager dependency
var menu = Helper.CreateMenu(localizedTitle, _backAction); var menu = Helper.CreateMenu(title, _backAction);
if (menu == null) return; if (menu == null) return;
foreach (var option in _options) foreach (var option in _options)
@ -179,10 +115,7 @@ public class MenuBuilder
} }
} }
// Get localized option name menu.AddMenuOption(option.Name, (menuPlayer, menuOption) =>
var localizedName = GetLocalizedOptionName(option);
menu.AddMenuOption(localizedName, (menuPlayer, menuOption) =>
{ {
option.Action?.Invoke(menuPlayer); option.Action?.Invoke(menuPlayer);
}, option.Disabled); }, option.Disabled);
@ -233,6 +166,5 @@ public class MenuOption
public Action<CCSPlayerController>? Action { get; set; } public Action<CCSPlayerController>? Action { get; set; }
public bool Disabled { get; set; } public bool Disabled { get; set; }
public string? Permission { get; set; } public string? Permission { get; set; }
public bool IsTranslationKey { get; set; }
} }

View file

@ -1,5 +1,4 @@
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Translations;
using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities;
@ -39,26 +38,6 @@ public class MenuManager
}; };
} }
/// <summary>
/// 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!
/// </summary>
/// <param name="categoryId">Unique identifier for the category.</param>
/// <param name="categoryNameKey">Translation key from module's lang files.</param>
/// <param name="permission">Required permission to access this category.</param>
/// <param name="moduleLocalizer">Module's IStringLocalizer for per-player translation.</param>
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<string, Func<CCSPlayerController, MenuBuilder>>(),
ModuleLocalizer = moduleLocalizer // Store module's localizer
};
}
/// <summary> /// <summary>
/// Registers a menu within a category (API for other plugins). /// Registers a menu within a category (API for other plugins).
/// </summary> /// </summary>
@ -87,37 +66,6 @@ public class MenuManager
} }
} }
/// <summary>
/// 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!
/// </summary>
/// <param name="categoryId">The category to add this menu to.</param>
/// <param name="menuId">Unique identifier for the menu.</param>
/// <param name="menuNameKey">Translation key from module's lang files.</param>
/// <param name="menuFactory">Factory function that creates the menu for a player.</param>
/// <param name="permission">Required permission to access this menu (optional).</param>
/// <param name="commandName">Command name for permission override checking (optional).</param>
/// <param name="moduleLocalizer">Module's IStringLocalizer for per-player translation.</param>
public void RegisterMenu(string categoryId, string menuId, string menuNameKey, Func<CCSPlayerController, MenuBuilder> 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;
}
}
/// <summary> /// <summary>
/// Unregisters a menu from a category. /// Unregisters a menu from a category.
/// </summary> /// </summary>
@ -140,7 +88,7 @@ public class MenuManager
public MenuBuilder CreateMainMenu(CCSPlayerController player) public MenuBuilder CreateMainMenu(CCSPlayerController player)
{ {
var localizer = CS2_SimpleAdmin._localizer; var localizer = CS2_SimpleAdmin._localizer;
var mainMenu = new MenuBuilder("sa_title", player, localizer); var mainMenu = new MenuBuilder(localizer?["sa_title"] ?? "SimpleAdmin");
foreach (var category in _menuCategories.Values) foreach (var category in _menuCategories.Values)
{ {
@ -150,23 +98,8 @@ public class MenuManager
if (!AdminManager.PlayerHasPermissions(steamId, category.Permission)) if (!AdminManager.PlayerHasPermissions(steamId, category.Permission))
continue; 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 // Pass player to CreateCategoryMenu
mainMenu.AddSubMenu(localizedCategoryName, () => CreateCategoryMenu(category, player), mainMenu.AddSubMenu(category.Name, () => CreateCategoryMenu(category, player),
permission: category.Permission); permission: category.Permission);
} }
@ -181,24 +114,7 @@ public class MenuManager
/// <returns>A MenuBuilder instance for the category menu.</returns> /// <returns>A MenuBuilder instance for the category menu.</returns>
private MenuBuilder CreateCategoryMenu(MenuCategory category, CCSPlayerController player) private MenuBuilder CreateCategoryMenu(MenuCategory category, CCSPlayerController player)
{ {
var localizer = CS2_SimpleAdmin._localizer; var categoryMenu = new MenuBuilder(category.Name);
// 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) foreach (var kvp in category.MenuFactories)
{ {
@ -243,30 +159,8 @@ public class MenuManager
continue; 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 // Call the actual factory with player parameter
categoryMenu.AddSubMenu(localizedMenuName, () => menuFactory(player), permission: permission); categoryMenu.AddSubMenu(menuName, () => menuFactory(player), permission: permission);
} }
return categoryMenu.WithBackButton(); return categoryMenu.WithBackButton();
@ -296,12 +190,12 @@ public class MenuManager
/// </summary> /// </summary>
public void InitializeDefaultCategories() public void InitializeDefaultCategories()
{ {
// Register categories with translation keys instead of translated names var localizer = CS2_SimpleAdmin._localizer;
// The actual translation will happen per-player in CreateMainMenu/CreateCategoryMenu
RegisterCategory("players", "sa_menu_players_manage", "@css/generic"); RegisterCategory("players", localizer?["sa_menu_players_manage"] ?? "Manage Players", "@css/generic");
RegisterCategory("server", "sa_menu_server_manage", "@css/generic"); RegisterCategory("server", localizer?["sa_menu_server_manage"] ?? "Server Management", "@css/generic");
// RegisterCategory("fun", "sa_menu_fun_commands", "@css/generic"); // RegisterCategory("fun", localizer?["sa_menu_fun_commands"] ?? "Fun Commands", "@css/generic");
RegisterCategory("admin", "sa_menu_admins_manage", "@css/root"); RegisterCategory("admin", localizer?["sa_menu_admins_manage"] ?? "Admin Management", "@css/root");
} }
/// <summary> /// <summary>
@ -328,17 +222,4 @@ public class MenuCategory
public Dictionary<string, string> MenuNames { get; set; } = []; public Dictionary<string, string> MenuNames { get; set; } = [];
public Dictionary<string, string> MenuPermissions { get; set; } = []; public Dictionary<string, string> MenuPermissions { get; set; } = [];
public Dictionary<string, string> MenuCommandNames { get; set; } = []; public Dictionary<string, string> MenuCommandNames { get; set; } = [];
// 🆕 NEW: Support for per-player localization in modules
/// <summary>
/// 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).
/// </summary>
public Microsoft.Extensions.Localization.IStringLocalizer? ModuleLocalizer { get; set; }
/// <summary>
/// Stores IStringLocalizer for each menu that uses module localization.
/// Key: menuId, Value: module's localizer
/// </summary>
public Dictionary<string, Microsoft.Extensions.Localization.IStringLocalizer> MenuLocalizers { get; set; } = [];
} }

View file

@ -41,12 +41,11 @@ public static class PlayersMenu
continue; continue;
var enabled = admin.CanTarget(player); var enabled = admin.CanTarget(player);
var capturedPlayer = player; // Capture in local variable to avoid closure issues
if (optionName != null) if (optionName != null)
menu?.AddMenuOption(optionName, (controller, option) => menu?.AddMenuOption(optionName, (_, _) =>
{ {
if (capturedPlayer != null) onSelectAction.Invoke(admin, capturedPlayer); if (player != null) onSelectAction.Invoke(admin, player);
}, },
!enabled); !enabled);
} }

View file

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

View file

@ -1,9 +0,0 @@
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; }
}

View file

@ -1 +1 @@
1.8.2b 1.7.8-beta-3

View file

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

View file

@ -31,7 +31,7 @@
"sa_team_spec": "Spec", "sa_team_spec": "Spec",
"sa_slap": "Slap", "sa_slap": "Slap",
"sa_slay": "Slay", "sa_slay": "slay",
"sa_kick": "Kick", "sa_kick": "Kick",
"sa_ban": "Ban", "sa_ban": "Ban",
"sa_gag": "Gag", "sa_gag": "Gag",

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>CS2_SimpleAdminApi</RootNamespace> <RootNamespace>CS2_SimpleAdminApi</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.369" /> <PackageReference Include="CounterStrikeSharp.API" Version="1.0.340" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -140,16 +140,6 @@ public interface ICS2_SimpleAdminApi
/// </summary> /// </summary>
void RegisterMenuCategory(string categoryId, string categoryName, string permission = "@css/generic"); void RegisterMenuCategory(string categoryId, string categoryName, string permission = "@css/generic");
/// <summary>
/// Registers a menu category with per-player localization support for modules.
/// 🆕 NEW: Supports per-player localization using module's IStringLocalizer!
/// </summary>
/// <param name="categoryId">The category ID (unique identifier).</param>
/// <param name="categoryNameKey">Translation key from module's lang files.</param>
/// <param name="permission">Required permission to access this category.</param>
/// <param name="moduleLocalizer">Module's IStringLocalizer for per-player translation.</param>
void RegisterMenuCategory(string categoryId, string categoryNameKey, string permission, object moduleLocalizer);
/// <summary> /// <summary>
/// Registers a menu in a category. /// Registers a menu in a category.
/// </summary> /// </summary>
@ -173,19 +163,6 @@ public interface ICS2_SimpleAdminApi
/// <param name="commandName">Command name for permission override checking (optional, e.g., "css_god").</param> /// <param name="commandName">Command name for permission override checking (optional, e.g., "css_god").</param>
void RegisterMenu(string categoryId, string menuId, string menuName, Func<CCSPlayerController, MenuContext, object> menuFactory, string? permission = null, string? commandName = null); void RegisterMenu(string categoryId, string menuId, string menuName, Func<CCSPlayerController, MenuContext, object> menuFactory, string? permission = null, string? commandName = null);
/// <summary>
/// Registers a menu with per-player localization support for modules.
/// 🆕 NEW: Supports per-player localization using module's IStringLocalizer!
/// </summary>
/// <param name="categoryId">The category to add this menu to.</param>
/// <param name="menuId">Unique identifier for the menu.</param>
/// <param name="menuNameKey">Translation key from module's lang files.</param>
/// <param name="menuFactory">Factory function that receives player and menu context.</param>
/// <param name="permission">Required permission to access this menu (optional).</param>
/// <param name="commandName">Command name for permission override checking (optional).</param>
/// <param name="moduleLocalizer">Module's IStringLocalizer for per-player translation.</param>
void RegisterMenu(string categoryId, string menuId, string menuNameKey, Func<CCSPlayerController, MenuContext, object> menuFactory, string? permission, string? commandName, object moduleLocalizer);
/// <summary> /// <summary>
/// Unregisters a menu from a category. /// Unregisters a menu from a category.
/// </summary> /// </summary>

View file

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

View file

@ -145,38 +145,20 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin
// STEP 1: Register a menu category // STEP 1: Register a menu category
// This creates a new section in the main admin menu // This creates a new section in the main admin menu
// Permission: @css/generic means all admins can see it // Permission: @css/generic means all admins can see it
//
// ⚠️ LOCALIZATION OPTIONS:
//
// OPTION A - No translations (hard-coded text):
_sharedApi.RegisterMenuCategory( _sharedApi.RegisterMenuCategory(
"example", // Category ID (unique identifier) "example", // Category ID (unique identifier)
"Example Features", // Display name (hard-coded, same for all players) "Example Features", // Display name in admin menu
"@css/generic" // Required permission "@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 // STEP 2: Register individual menu items in the category
// 🆕 NEW: These use MenuContext API - factory receives (admin, context) parameters // 🆕 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 // Example 1: Simple menu with options
_sharedApi.RegisterMenu( _sharedApi.RegisterMenu(
"example", // Category ID "example", // Category ID
"simple_action", // Menu ID (unique within category) "simple_action", // Menu ID (unique within category)
"Simple Actions", // Display name (hard-coded) "Simple Actions", // Display name
CreateSimpleActionMenu, // Factory method CreateSimpleActionMenu, // Factory method
"@css/generic" // Required permission "@css/generic" // Required permission
); );
@ -185,7 +167,7 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin
_sharedApi.RegisterMenu( _sharedApi.RegisterMenu(
"example", "example",
"player_selection", "player_selection",
"Select Player", // Display name "Select Player",
CreatePlayerSelectionMenu, CreatePlayerSelectionMenu,
"@css/kick" // Requires kick permission "@css/kick" // Requires kick permission
); );
@ -194,7 +176,7 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin
_sharedApi.RegisterMenu( _sharedApi.RegisterMenu(
"example", "example",
"nested_menu", "nested_menu",
"Give Credits", // Display name "Give Credits",
CreateGiveCreditsMenu, CreateGiveCreditsMenu,
"@css/generic" "@css/generic"
); );
@ -203,26 +185,12 @@ public class CS2_SimpleAdmin_ExampleModule: BasePlugin
_sharedApi.RegisterMenu( _sharedApi.RegisterMenu(
"example", "example",
"test_command", "test_command",
"Test Command", // Display name "Test Command",
CreateTestCommandMenu, CreateTestCommandMenu,
"@css/root", // Default permission "@css/root", // Default permission
"css_test" // Command name for override checking "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; _menusRegistered = true;
Logger.LogInformation("Example menus registered successfully!"); Logger.LogInformation("Example menus registered successfully!");
} }

View file

@ -366,73 +366,66 @@ public partial class CS2_SimpleAdmin_FunCommands : BasePlugin, IPluginConfig<Con
try try
{ {
// 🆕 NEW: Per-player localization support for modules! _sharedApi.RegisterMenuCategory("fun", Localizer?["fun_category_name"] ?? "Fun Commands", "@css/generic");
// - This module has its own lang/ folder with translations
// - We pass translation KEYS and the module's Localizer to the API
// - SimpleAdmin will translate menu names per-player based on their css_lang
// - Each player sees menus in their own language!
_sharedApi.RegisterMenuCategory("fun", "fun_category_name", "@css/generic", Localizer!);
// Register menus with command names for permission override support // Register menus with command names for permission override support
// Server admins can override default permissions via CounterStrikeSharp admin system // Server admins can override default permissions via CounterStrikeSharp admin system
// Example: If "css_god" is overridden to "@css/vip", only VIPs will see the God Mode menu // Example: If "css_god" is overdden to "@css/vip", only VIPs will see the God Mode menu
//
// 🆕 NEW: All menus use translation keys and module's Localizer for per-player localization!
if (Config.GodCommands.Count > 0) if (Config.GodCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "god", _sharedApi.RegisterMenu("fun", "god",
"fun_menu_god", Localizer?["fun_menu_god"] ?? "God Mode",
CreateGodModeMenu, "@css/cheats", "css_god", Localizer!); CreateGodModeMenu, "@css/cheats", "css_god");
if (Config.NoclipCommands.Count > 0) if (Config.NoclipCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "noclip", _sharedApi.RegisterMenu("fun", "noclip",
"fun_menu_noclip", Localizer?["fun_menu_noclip"] ?? "No Clip",
CreateNoClipMenu, "@css/cheats", "css_noclip", Localizer!); CreateNoClipMenu, "@css/cheats", "css_noclip");
if (Config.RespawnCommands.Count > 0) if (Config.RespawnCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "respawn", _sharedApi.RegisterMenu("fun", "respawn",
"fun_menu_respawn", Localizer?["fun_menu_respawn"] ?? "Respawn",
CreateRespawnMenu, "@css/cheats", "css_respawn", Localizer!); CreateRespawnMenu, "@css/cheats", "css_respawn");
if (Config.GiveCommands.Count > 0) if (Config.GiveCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "give", _sharedApi.RegisterMenu("fun", "give",
"fun_menu_give", Localizer?["fun_menu_give"] ?? "Give Weapon",
CreateGiveWeaponMenu, "@css/cheats", "css_give", Localizer!); CreateGiveWeaponMenu, "@css/cheats", "css_give");
if (Config.StripCommands.Count > 0) if (Config.StripCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "strip", _sharedApi.RegisterMenu("fun", "strip",
"fun_menu_strip", Localizer?["fun_menu_strip"] ?? "Strip Weapons",
CreateStripWeaponsMenu, "@css/slay", "css_strip", Localizer!); CreateStripWeaponsMenu, "@css/slay", "css_strip");
if (Config.FreezeCommands.Count > 0) if (Config.FreezeCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "freeze", _sharedApi.RegisterMenu("fun", "freeze",
"fun_menu_freeze", Localizer?["fun_menu_freeze"] ?? "Freeze",
CreateFreezeMenu, "@css/slay", "css_freeze", Localizer!); CreateFreezeMenu, "@css/slay", "css_freeze");
if (Config.HpCommands.Count > 0) if (Config.HpCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "hp", _sharedApi.RegisterMenu("fun", "hp",
"fun_menu_hp", Localizer?["fun_menu_hp"] ?? "Set HP",
CreateSetHpMenu, "@css/slay", "css_hp", Localizer!); CreateSetHpMenu, "@css/slay", "css_hp");
if (Config.SpeedCommands.Count > 0) if (Config.SpeedCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "speed", _sharedApi.RegisterMenu("fun", "speed",
"fun_menu_speed", Localizer?["fun_menu_speed"] ?? "Set Speed",
CreateSetSpeedMenu, "@css/slay", "css_speed", Localizer!); CreateSetSpeedMenu, "@css/slay", "css_speed");
if (Config.GravityCommands.Count > 0) if (Config.GravityCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "gravity", _sharedApi.RegisterMenu("fun", "gravity",
"fun_menu_gravity", Localizer?["fun_menu_gravity"] ?? "Set Gravity",
CreateSetGravityMenu, "@css/slay", "css_gravity", Localizer!); CreateSetGravityMenu, "@css/slay", "css_gravity");
if (Config.MoneyCommands.Count > 0) if (Config.MoneyCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "money", _sharedApi.RegisterMenu("fun", "money",
"fun_menu_money", Localizer?["fun_menu_money"] ?? "Set Money",
CreateSetMoneyMenu, "@css/slay", "css_money", Localizer!); CreateSetMoneyMenu, "@css/slay", "css_money");
if (Config.ResizeCommands.Count > 0) if (Config.ResizeCommands.Count > 0)
_sharedApi.RegisterMenu("fun", "resize", _sharedApi.RegisterMenu("fun", "resize",
"fun_menu_resize", Localizer?["fun_menu_resize"] ?? "Resize Player",
CreateSetResizeMenu, "@css/slay", "css_resize", Localizer!); CreateSetResizeMenu, "@css/slay", "css_resize");
_menusRegistered = true; _menusRegistered = true;
Logger.LogInformation("Fun menus registered successfully!"); Logger.LogInformation("Fun menus registered successfully!");

View file

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

View file

@ -1,7 +1,6 @@
using System.Globalization; using System.Globalization;
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Translations;
namespace CS2_SimpleAdmin_FunCommands; namespace CS2_SimpleAdmin_FunCommands;
@ -140,15 +139,8 @@ public partial class CS2_SimpleAdmin_FunCommands
/// </summary> /// </summary>
private object CreateWeaponSelectionMenu(CCSPlayerController admin, CCSPlayerController target) 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( var weaponMenu = _sharedApi!.CreateMenuWithBack(
translatedTitle, Localizer?["fun_menu_give_player", target.PlayerName] ?? $"Give Weapon: {target.PlayerName}",
"fun", "fun",
admin); admin);
@ -220,15 +212,8 @@ public partial class CS2_SimpleAdmin_FunCommands
/// </summary> /// </summary>
private object CreateHpSelectionMenu(CCSPlayerController admin, CCSPlayerController target) 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( var hpSelectionMenu = _sharedApi!.CreateMenuWithBack(
translatedTitle, Localizer?["fun_menu_hp_player", target.PlayerName] ?? $"Set HP: {target.PlayerName}",
"fun", "fun",
admin); admin);
@ -237,15 +222,8 @@ public partial class CS2_SimpleAdmin_FunCommands
foreach (var hp in hpValues) 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, _sharedApi.AddMenuOption(hpSelectionMenu,
optionLabel, Localizer?["fun_menu_hp_value", hp] ?? $"{hp} HP",
_ => _ =>
{ {
if (target.IsValid) if (target.IsValid)
@ -283,15 +261,8 @@ public partial class CS2_SimpleAdmin_FunCommands
/// </summary> /// </summary>
private object CreateSpeedSelectionMenu(CCSPlayerController admin, CCSPlayerController target) 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( var speedSelectionMenu = _sharedApi!.CreateMenuWithBack(
translatedTitle, Localizer?["fun_menu_speed_player", target.PlayerName] ?? $"Set Speed: {target.PlayerName}",
"fun", "fun",
admin); admin);
@ -304,15 +275,8 @@ public partial class CS2_SimpleAdmin_FunCommands
foreach (var (speed, display) in speedValues) 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, _sharedApi.AddMenuOption(speedSelectionMenu,
optionLabel, Localizer?["fun_menu_speed_value", display] ?? $"Speed {display}",
_ => _ =>
{ {
if (target.IsValid) if (target.IsValid)
@ -352,15 +316,8 @@ public partial class CS2_SimpleAdmin_FunCommands
private object CreateGravitySelectionMenu(CCSPlayerController admin, CCSPlayerController target) 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( var gravitySelectionMenu = _sharedApi!.CreateMenuWithBack(
translatedTitle, Localizer?["fun_menu_gravity_player", target.PlayerName] ?? $"Set Gravity: {target.PlayerName}",
"fun", "fun",
admin); admin);
var gravityValues = new[] var gravityValues = new[]
@ -408,15 +365,8 @@ public partial class CS2_SimpleAdmin_FunCommands
private object CreateMoneySelectionMenu(CCSPlayerController admin, CCSPlayerController target) 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( var moneySelectionMenu = _sharedApi!.CreateMenuWithBack(
translatedTitle, Localizer?["fun_menu_money_player", target.PlayerName] ?? $"Set Money: {target.PlayerName}",
"fun", "fun",
admin); admin);
var moneyValues = new[] { 0, 1000, 2500, 5000, 10000, 16000 }; var moneyValues = new[] { 0, 1000, 2500, 5000, 10000, 16000 };
@ -457,15 +407,8 @@ public partial class CS2_SimpleAdmin_FunCommands
private object CreateResizeSelectionMenu(CCSPlayerController admin, CCSPlayerController target) 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( var resizeSelectionMenu = _sharedApi!.CreateMenuWithBack(
translatedTitle, Localizer?["fun_menu_resize_player", target.PlayerName] ?? $"Resize: {target.PlayerName}",
"fun", "fun",
admin); admin);
var resizeValues = new[] var resizeValues = new[]

View file

@ -75,17 +75,10 @@ public class MyModule : BasePlugin
if (_api == null) return; if (_api == null) return;
// 1. Register a new category // 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"); _api.RegisterMenuCategory("mymodule", "My Module", "@css/generic");
// 2. Register menu items in the category // 2. Register menu items in the category
// 🆕 NEW: Use MenuContext-aware overload (no duplication!) // 🆕 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", "action1", "Action 1", CreateAction1Menu, "@css/generic");
_api.RegisterMenu("mymodule", "action2", "Action 2", CreateAction2Menu, "@css/kick"); _api.RegisterMenu("mymodule", "action2", "Action 2", CreateAction2Menu, "@css/kick");
} }
@ -363,27 +356,12 @@ Registers a new menu category that appears in the main admin menu.
**Parameters:** **Parameters:**
- `categoryId` - Unique identifier for the category (e.g., "fun", "vip", "economy") - `categoryId` - Unique identifier for the category (e.g., "fun", "vip", "economy")
- `categoryName` - **TRANSLATION KEY** for the display name (e.g., "vip_category_name", NOT "VIP Features") - `categoryName` - Display name shown in menu (e.g., "Fun Commands")
- `permission` - Required permission to see the category (default: "@css/generic") - `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:** **Example:**
```csharp ```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"); _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 ### 2. Menu Registration
@ -395,28 +373,13 @@ Registers a menu item within a category.
**Parameters:** **Parameters:**
- `categoryId` - The category to add this menu to - `categoryId` - The category to add this menu to
- `menuId` - Unique identifier for the menu - `menuId` - Unique identifier for the menu
- `menuName` - Display name for the menu (use **your module's Localizer** if you have translations) - `menuName` - Display name in the category menu
- `menuFactory` - Function that creates the menu when selected (receives admin player and MenuContext) - `menuFactory` - Function that creates the menu when selected (receives admin player)
- `permission` - Optional permission required to see this menu item - `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:** **Example:**
```csharp ```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"); _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)` #### `UnregisterMenu(string categoryId, string menuId)`
@ -662,32 +625,12 @@ private object CreateAdminToolsMenu(CCSPlayerController admin)
## Best Practices ## Best Practices
1. **Use your module's Localizer for translations** 🌍 1. **Always check for API availability**
```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 ```csharp
if (_api == null) return; if (_api == null) return;
``` ```
3. **Validate player state before actions** 2. **Validate player state before actions**
```csharp ```csharp
if (target.IsValid && target.PlayerPawn?.Value != null) if (target.IsValid && target.PlayerPawn?.Value != null)
{ {
@ -695,11 +638,11 @@ private object CreateAdminToolsMenu(CCSPlayerController admin)
} }
``` ```
4. **Use descriptive category and menu IDs** 3. **Use descriptive category and menu IDs**
- Good: `"economy"`, `"vip_features"`, `"fun_commands"` - Good: `"economy"`, `"vip_features"`, `"fun_commands"`
- Bad: `"cat1"`, `"menu"`, `"test"` - Bad: `"cat1"`, `"menu"`, `"test"`
5. **Clean up on unload** 4. **Clean up on unload**
```csharp ```csharp
public override void Unload(bool hotReload) public override void Unload(bool hotReload)
{ {
@ -708,14 +651,14 @@ private object CreateAdminToolsMenu(CCSPlayerController admin)
} }
``` ```
6. **Use appropriate permissions** 5. **Use appropriate permissions**
- `@css/generic` - All admins - `@css/generic` - All admins
- `@css/ban` - Admins who can ban - `@css/ban` - Admins who can ban
- `@css/kick` - Admins who can kick - `@css/kick` - Admins who can kick
- `@css/root` - Root admins only - `@css/root` - Root admins only
- Custom permissions from your module - Custom permissions from your module
7. **Handle hot reload** 6. **Handle hot reload**
```csharp ```csharp
_api.OnSimpleAdminReady += RegisterMenus; _api.OnSimpleAdminReady += RegisterMenus;
RegisterMenus(); // Fallback for hot reload case RegisterMenus(); // Fallback for hot reload case
@ -774,72 +717,3 @@ See the `CS2-SimpleAdmin_FunCommands` module in the `Modules/` directory for a c
**Q: API is null in OnAllPluginsLoaded** **Q: API is null in OnAllPluginsLoaded**
- Wait for the `OnSimpleAdminReady` event instead of immediate registration - Wait for the `OnSimpleAdminReady` event instead of immediate registration
- Make sure CS2-SimpleAdmin is loaded before your module - 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");
```