using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Capabilities; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities.Constants; using CS2_SimpleAdminApi; using Microsoft.Extensions.Logging; namespace CS2_SimpleAdmin_ExampleModule; /// /// COMPLETE EXAMPLE MODULE FOR CS2-SIMPLEADMIN /// /// This module demonstrates: /// 1. ✅ Getting CS2-SimpleAdmin API via capability system /// 2. ✅ Using API methods (GetServerId, GetConnectionString, IssuePenalty) /// 3. ✅ Listening to events (OnPlayerPenaltied, OnPlayerPenaltiedAdded) /// 4. ✅ Registering console commands /// 5. ✅ Creating menu categories and menu items /// 6. ✅ Using NEW MenuContext API to eliminate code duplication /// 7. ✅ Proper cleanup on module unload /// /// Study this file to learn how to create your own CS2-SimpleAdmin modules! /// public class CS2_SimpleAdmin_ExampleModule: BasePlugin { // ======================================== // PLUGIN METADATA // ======================================== public override string ModuleName => "[CS2-SimpleAdmin] Example Module"; public override string ModuleVersion => "v1.1.0"; public override string ModuleAuthor => "daffyy & Example Contributors"; // ======================================== // PRIVATE FIELDS // ======================================== /// /// Server ID from SimpleAdmin (null for single-server mode) /// Useful for multi-server setups to identify which server this is /// private int? _serverId; /// /// Database connection string from SimpleAdmin /// Use this if your module needs direct database access /// private string _dbConnectionString = string.Empty; /// /// Reference to CS2-SimpleAdmin API /// Use this to call API methods and register menus /// private static ICS2_SimpleAdminApi? _sharedApi; /// /// Capability for getting the SimpleAdmin API /// This is the recommended way to get access to another plugin's API /// private readonly PluginCapability _pluginCapability = new("simpleadmin:api"); /// /// Flag to prevent duplicate menu registration /// Important for hot reload scenarios /// private bool _menusRegistered = false; // ======================================== // PLUGIN LIFECYCLE // ======================================== /// /// Called when all plugins are loaded (including hot reload) /// BEST PRACTICE: Use this instead of Load() to ensure all dependencies are available /// public override void OnAllPluginsLoaded(bool hotReload) { // STEP 1: Get the SimpleAdmin API using capability system try { _sharedApi = _pluginCapability.Get(); } catch (Exception) { Logger.LogError("CS2-SimpleAdmin API not found - make sure CS2-SimpleAdmin is loaded!"); Unload(false); return; } // STEP 2: Get server information from SimpleAdmin _serverId = _sharedApi.GetServerId(); _dbConnectionString = _sharedApi.GetConnectionString(); Logger.LogInformation($"{ModuleName} started with serverId {_serverId}"); // STEP 3: Subscribe to SimpleAdmin events // These events fire when penalties (ban, kick, mute, etc.) are issued _sharedApi.OnPlayerPenaltied += OnPlayerPenaltied; // When penalty is issued to ONLINE player _sharedApi.OnPlayerPenaltiedAdded += OnPlayerPenaltiedAdded; // When penalty is issued to OFFLINE player // STEP 4: Register menus // BEST PRACTICE: Wait for SimpleAdmin to be ready before registering menus // This handles both normal load and hot reload scenarios _sharedApi.OnSimpleAdminReady += RegisterExampleMenus; RegisterExampleMenus(); // Fallback for hot reload case } /// /// Called when the plugin is being unloaded /// BEST PRACTICE: Always clean up your registrations to prevent memory leaks /// public override void Unload(bool hotReload) { if (_sharedApi == null) return; // Unsubscribe from events _sharedApi.OnPlayerPenaltied -= OnPlayerPenaltied; _sharedApi.OnPlayerPenaltiedAdded -= OnPlayerPenaltiedAdded; _sharedApi.OnSimpleAdminReady -= RegisterExampleMenus; // Unregister menus _sharedApi.UnregisterMenu("example", "simple_action"); _sharedApi.UnregisterMenu("example", "player_selection"); _sharedApi.UnregisterMenu("example", "nested_menu"); _sharedApi.UnregisterMenu("example", "test_command"); Logger.LogInformation($"{ModuleName} unloaded successfully"); } // ======================================== // MENU REGISTRATION // ======================================== /// /// Registers all example menus in the admin menu /// BEST PRACTICE: Use this pattern to prevent duplicate registrations /// private void RegisterExampleMenus() { if (_sharedApi == null || _menusRegistered) return; try { // STEP 1: Register a menu category // This creates a new section in the main admin menu // Permission: @css/generic means all admins can see it // // ⚠️ LOCALIZATION OPTIONS: // // OPTION A - No translations (hard-coded text): _sharedApi.RegisterMenuCategory( "example", // Category ID (unique identifier) "Example Features", // Display name (hard-coded, same for all players) "@css/generic" // Required permission ); // // OPTION B - With per-player translations (🆕 NEW!): // If your module has lang/ folder with translations, use this pattern: // _sharedApi.RegisterMenuCategory( // "example", // Category ID // "example_category_name", // Translation key // "@css/generic", // Permission // Localizer! // Module's localizer // ); // This will translate the category name per-player based on their css_lang setting! // STEP 2: Register individual menu items in the category // 🆕 NEW: These use MenuContext API - factory receives (admin, context) parameters // // ⚠️ LOCALIZATION OPTIONS: // // OPTION A - No translations (hard-coded text): // Example 1: Simple menu with options _sharedApi.RegisterMenu( "example", // Category ID "simple_action", // Menu ID (unique within category) "Simple Actions", // Display name (hard-coded) CreateSimpleActionMenu, // Factory method "@css/generic" // Required permission ); // Example 2: Player selection menu _sharedApi.RegisterMenu( "example", "player_selection", "Select Player", // Display name CreatePlayerSelectionMenu, "@css/kick" // Requires kick permission ); // Example 3: Nested menu (Player → Value) _sharedApi.RegisterMenu( "example", "nested_menu", "Give Credits", // Display name CreateGiveCreditsMenu, "@css/generic" ); // Example 4: Menu with permission override support _sharedApi.RegisterMenu( "example", "test_command", "Test Command", // Display name CreateTestCommandMenu, "@css/root", // Default permission "css_test" // Command name for override checking ); // OPTION B - With per-player translations (🆕 NEW!): // If your module has lang/ folder, use this pattern: // _sharedApi.RegisterMenu( // "example", // Category ID // "menu_id", // Menu ID // "menu_translation_key", // Translation key (NOT translated text!) // CreateYourMenu, // Factory method // "@css/generic", // Permission // "css_command", // Command name (optional) // Localizer! // Module's localizer // ); // This will translate the menu name per-player based on their css_lang! // See FunCommands module for real example. _menusRegistered = true; Logger.LogInformation("Example menus registered successfully!"); } catch (Exception ex) { Logger.LogError($"Failed to register example menus: {ex.Message}"); } } // ======================================== // MENU FACTORY METHODS // ======================================== /// /// PATTERN 1: Simple menu with static options /// 🆕 NEW: Uses MenuContext to eliminate duplication! /// private object CreateSimpleActionMenu(CCSPlayerController admin, MenuContext context) { // Create menu with automatic back button // 🆕 NEW: Use context instead of repeating title and category! var menu = _sharedApi!.CreateMenuWithBack(context, admin); // Add menu options _sharedApi.AddMenuOption(menu, "Print Server Info", player => { player.PrintToChat($"Server ID: {_serverId}"); player.PrintToChat($"Server IP: {_sharedApi?.GetServerAddress()}"); }); _sharedApi.AddMenuOption(menu, "Get My Stats", player => { try { var playerInfo = _sharedApi?.GetPlayerInfo(player); player.PrintToChat($"Total Bans: {playerInfo?.TotalBans ?? 0}"); player.PrintToChat($"Total Kicks: {playerInfo?.TotalKicks ?? 0}"); player.PrintToChat($"Total Warns: {playerInfo?.TotalWarns ?? 0}"); } catch (Exception ex) { Logger.LogError($"Error getting player info: {ex.Message}"); player.PrintToChat("Error retrieving your stats"); } }); _sharedApi.AddMenuOption(menu, "Check Silent Mode", player => { var isSilent = _sharedApi?.IsAdminSilent(player) ?? false; player.PrintToChat($"Silent mode: {(isSilent ? "ON" : "OFF")}"); }); return menu; } /// /// PATTERN 2: Player selection menu with immediate action /// 🆕 NEW: Uses MenuContext API - cleaner and less error-prone! /// private object CreatePlayerSelectionMenu(CCSPlayerController admin, MenuContext context) { // 🆕 NEW: CreateMenuWithPlayers now uses context instead of title/category return _sharedApi!.CreateMenuWithPlayers( context, // ← Contains title and category automatically! admin, // Filter: Only show valid players that admin can target player => player.IsValid && admin.CanTarget(player), // Action: What happens when a player is selected (adminPlayer, targetPlayer) => { adminPlayer.PrintToChat($"You selected: {targetPlayer.PlayerName}"); // Example: Show player info try { var playerInfo = _sharedApi?.GetPlayerInfo(targetPlayer); adminPlayer.PrintToChat($"{targetPlayer.PlayerName} - Bans: {playerInfo?.TotalBans}, Warns: {playerInfo?.TotalWarns}"); } catch (Exception ex) { Logger.LogWarning($"Could not get info for {targetPlayer.PlayerName}: {ex.Message}"); } } ); } /// /// PATTERN 3: Nested menu (Player → Value selection) /// 🆕 NEW: First level menu uses MenuContext /// private object CreateGiveCreditsMenu(CCSPlayerController admin, MenuContext context) { // Create menu with back button // 🆕 NEW: Uses context - no more repeating title/category! var menu = _sharedApi!.CreateMenuWithBack(context, admin); // Get all valid, targetable players var players = _sharedApi.GetValidPlayers().Where(p => p.PlayerPawn?.Value?.LifeState == (int)LifeState_t.LIFE_ALIVE && admin.CanTarget(p)); foreach (var player in players) { var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName; // AddSubMenu automatically adds a "Back" button to the submenu _sharedApi.AddSubMenu(menu, playerName, p => CreateCreditAmountMenu(admin, player)); } return menu; } /// /// Submenu for selecting credit amount /// Note: Submenus create dynamic titles, so they don't receive MenuContext /// private object CreateCreditAmountMenu(CCSPlayerController admin, CCSPlayerController target) { // Dynamic title includes target's name var menu = _sharedApi!.CreateMenuWithBack( $"Credits for {target.PlayerName}", "example", // Category for back navigation admin ); // Predefined credit amounts var creditAmounts = new[] { 100, 500, 1000, 5000, 10000 }; foreach (var amount in creditAmounts) { _sharedApi.AddMenuOption(menu, $"{amount} Credits", _ => { // BEST PRACTICE: Always validate player is still valid before action if (target.IsValid) { Server.PrintToChatAll($"{admin.PlayerName} gave {amount} credits to {target.PlayerName}"); Logger.LogInformation($"Admin {admin.PlayerName} gave {amount} credits to {target.PlayerName}"); } else { admin.PrintToChat("Player is no longer available"); } }); } return menu; } /// /// Example menu with permission override support /// private object CreateTestCommandMenu(CCSPlayerController admin, MenuContext context) { var menu = _sharedApi!.CreateMenuWithBack(context, admin); // You can access context properties if needed _sharedApi.AddMenuOption(menu, "Show Context Info", player => { player.PrintToChat($"Category: {context.CategoryId}"); player.PrintToChat($"Menu ID: {context.MenuId}"); player.PrintToChat($"Title: {context.MenuTitle}"); player.PrintToChat($"Permission: {context.Permission}"); player.PrintToChat($"Command: {context.CommandName}"); }); _sharedApi.AddMenuOption(menu, "Test Action", player => { player.PrintToChat("Test action executed!"); }); return menu; } // ======================================== // CONSOLE COMMANDS // ======================================== /// /// Example command: Kick yourself /// Demonstrates using IssuePenalty API for online players /// [ConsoleCommand("css_kickme")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] public void KickMeCommand(CCSPlayerController? caller, CommandInfo commandInfo) { if (caller == null) return; // Issue a kick penalty to the caller // Parameters: player, admin (null = console), penaltyType, reason _sharedApi?.IssuePenalty(caller, null, PenaltyType.Kick, "You kicked yourself!"); } /// /// Example command: Get server address /// Demonstrates using GetServerAddress API /// [ConsoleCommand("css_serveraddress")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] public void ServerAddressCommand(CCSPlayerController? caller, CommandInfo commandInfo) { commandInfo.ReplyToCommand($"Server IP: {_sharedApi?.GetServerAddress()}"); } /// /// Example command: Get player statistics /// Demonstrates using GetPlayerInfo API /// [ConsoleCommand("css_getmyinfo")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] public void GetMyInfoCommand(CCSPlayerController? caller, CommandInfo commandInfo) { if (caller == null) return; try { var playerInfo = _sharedApi?.GetPlayerInfo(caller); commandInfo.ReplyToCommand($"Your Statistics:"); commandInfo.ReplyToCommand($" Total Bans: {playerInfo?.TotalBans ?? 0}"); commandInfo.ReplyToCommand($" Total Kicks: {playerInfo?.TotalKicks ?? 0}"); commandInfo.ReplyToCommand($" Total Gags: {playerInfo?.TotalGags ?? 0}"); commandInfo.ReplyToCommand($" Total Mutes: {playerInfo?.TotalMutes ?? 0}"); commandInfo.ReplyToCommand($" Total Warns: {playerInfo?.TotalWarns ?? 0}"); commandInfo.ReplyToCommand($" SteamID: {playerInfo?.SteamId}"); } catch (Exception ex) { Logger.LogError($"Error in GetMyInfoCommand: {ex.Message}"); commandInfo.ReplyToCommand("Error retrieving your information"); } } /// /// Example command: Add ban to offline player /// Demonstrates using IssuePenalty API with SteamID for offline players /// SERVER ONLY - dangerous command! /// [ConsoleCommand("css_testaddban")] [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] public void OnAddBanCommand(CCSPlayerController? caller, CommandInfo commandInfo) { // Issue a ban to an offline player by SteamID // Parameters: steamID, admin (null = console), penaltyType, reason, duration (minutes) _sharedApi?.IssuePenalty( new SteamID(76561197960287930), // Target SteamID null, // Admin (null = console) PenaltyType.Ban, // Penalty type "Test ban from example module", // Reason 10 // Duration (10 minutes) ); Logger.LogInformation("Test ban issued via API"); } // ======================================== // EVENT HANDLERS // ======================================== /// /// Called when a penalty is issued to an ONLINE player /// Use this to react to bans/kicks/mutes happening in real-time /// private void OnPlayerPenaltied( PlayerInfo player, // The player who received the penalty PlayerInfo? admin, // The admin who issued it (null = console) PenaltyType penaltyType,// Type of penalty (Ban, Kick, Mute, etc.) string reason, // Reason for the penalty int duration, // Duration in minutes (-1 = permanent) int? penaltyId, // Database ID of the penalty int? serverId // Server ID where it was issued ) { // Example: Announce bans to all players if (penaltyType == PenaltyType.Ban) { var adminName = admin?.Name ?? "Console"; var durationText = (duration == -1 || duration == 0) ? "permanently" : $"for {duration} minutes"; Server.PrintToChatAll($"{player.Name} was banned {durationText} by {adminName}"); } // Log all penalties var adminNameLog = admin?.Name ?? "Console"; switch (penaltyType) { case PenaltyType.Ban: Logger.LogInformation($"Ban issued to {player.Name} by {adminNameLog} (ID: {penaltyId}, Duration: {duration}m, Reason: {reason})"); break; case PenaltyType.Kick: Logger.LogInformation($"Kick issued to {player.Name} by {adminNameLog} (Reason: {reason})"); break; case PenaltyType.Gag: Logger.LogInformation($"Gag issued to {player.Name} by {adminNameLog} (ID: {penaltyId}, Duration: {duration}m)"); break; case PenaltyType.Mute: Logger.LogInformation($"Mute issued to {player.Name} by {adminNameLog} (ID: {penaltyId}, Duration: {duration}m)"); break; case PenaltyType.Silence: Logger.LogInformation($"Silence issued to {player.Name} by {adminNameLog} (ID: {penaltyId}, Duration: {duration}m)"); break; case PenaltyType.Warn: Logger.LogInformation($"Warning issued to {player.Name} by {adminNameLog} (ID: {penaltyId}, Reason: {reason})"); break; } } /// /// Called when a penalty is issued to an OFFLINE player /// Use this to react to bans/mutes added via SteamID (player not on server) /// private void OnPlayerPenaltiedAdded( SteamID steamId, // SteamID of the penalized player PlayerInfo? admin, // The admin who issued it (null = console) PenaltyType penaltyType,// Type of penalty string reason, // Reason for the penalty int duration, // Duration in minutes (-1 = permanent) int? penaltyId, // Database ID of the penalty int? serverId // Server ID where it was issued ) { // Log offline penalty additions var adminName = admin?.Name ?? "Console"; switch (penaltyType) { case PenaltyType.Ban: Logger.LogInformation($"Ban added for offline player {steamId} by {adminName} (ID: {penaltyId}, Duration: {duration}m, Reason: {reason})"); break; case PenaltyType.Gag: Logger.LogInformation($"Gag added for offline player {steamId} by {adminName} (ID: {penaltyId}, Duration: {duration}m)"); break; case PenaltyType.Mute: Logger.LogInformation($"Mute added for offline player {steamId} by {adminName} (ID: {penaltyId}, Duration: {duration}m)"); break; case PenaltyType.Silence: Logger.LogInformation($"Silence added for offline player {steamId} by {adminName} (ID: {penaltyId}, Duration: {duration}m)"); break; case PenaltyType.Warn: Logger.LogInformation($"Warning added for offline player {steamId} by {adminName} (ID: {penaltyId}, Reason: {reason})"); break; } } }